Ver Fonte

Merge pull request #23 from ckeditor/t/18

ImageStyle feature.
Piotrek Koszuliński há 9 anos atrás
pai
commit
1a276a907b

+ 135 - 0
packages/ckeditor5-image/src/imagestyle/converters.js

@@ -0,0 +1,135 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module image/imagestyle/converters
+ */
+
+import { isImage } from '../utils';
+
+/**
+ * Returns converter for the `imageStyle` attribute. It can be used for adding, changing and removing the attribute.
+ *
+ * @param {Object} styles Object containing available styles. See {@link module:image/imagestyle/imagestyleengine~ImageStyleFormat}
+ * for more details.
+ * @returns {Function} Model to view attribute converter.
+ */
+export function modelToViewStyleAttribute( styles ) {
+	return ( evt, data, consumable, conversionApi ) => {
+		const eventType = evt.name.split( ':' )[ 0 ];
+		const consumableType = eventType + ':imageStyle';
+
+		if ( !consumable.test( data.item, consumableType ) ) {
+			return;
+		}
+
+		// Check if there is class name associated with given value.
+		const newStyle = getStyleByValue( data.attributeNewValue, styles );
+		const oldStyle = getStyleByValue( data.attributeOldValue, styles );
+		const viewElement = conversionApi.mapper.toViewElement( data.item );
+
+		if ( handleRemoval( eventType, oldStyle, viewElement ) || handleAddition( eventType, newStyle, viewElement ) ) {
+			consumable.consume( data.item, consumableType );
+		}
+	};
+}
+
+/**
+ * Returns view to model converter converting image CSS classes to proper value in the model.
+ *
+ * @param {Array.<module:image/imagestyle/imagestyleengine~ImageStyleFormat>} styles Styles for which converter is created.
+ * @returns {Function} View to model converter.
+ */
+export function viewToModelStyleAttribute( styles ) {
+	// Convert only styles without `null` value.
+	const filteredStyles = styles.filter( style => style.value !== null );
+
+	return ( evt, data, consumable, conversionApi ) => {
+		for ( let style of filteredStyles ) {
+			viewToModelImageStyle( style, data, consumable, conversionApi );
+		}
+	};
+}
+
+// Converter from view to model converting single style.
+// For more information see {@link module:engine/conversion/viewconversiondispatcher~ViewConversionDispatcher};
+//
+// @param {module:image/imagestyle/imagestyleengine~ImageStyleFormat} style
+// @param {Object} data
+// @param {module:engine/conversion/viewconsumable~ViewConsumable} consumable
+// @param {Object} conversionApi
+function viewToModelImageStyle( style, data, consumable, conversionApi ) {
+	const viewFigureElement = data.input;
+	const modelImageElement = data.output;
+
+	// *** Step 1: Validate conversion.
+	// Check if view element has proper class to consume.
+	if ( !consumable.test( viewFigureElement, { class: style.className } ) ) {
+		return;
+	}
+
+	// Check if figure is converted to image.
+	if ( !isImage( modelImageElement ) ) {
+		return;
+	}
+
+	// Check if image element can be placed in current context wit additional attribute.
+	const attributes = [ ...modelImageElement.getAttributeKeys(), 'imageStyle' ];
+
+	if ( !conversionApi.schema.check( { name: 'image', inside: data.context, attributes } ) ) {
+		return;
+	}
+
+	// *** Step2: Convert to model.
+	consumable.consume( viewFigureElement, { class: style.className } );
+	modelImageElement.setAttribute( 'imageStyle', style.value );
+}
+
+// Returns style with given `value` from array of styles.
+//
+// @param {String} value
+// @param {Array.<module:image/imagestyle/imagestyleengine~ImageStyleFormat> } styles
+// @return {module:image/imagestyle/imagestyleengine~ImageStyleFormat|undefined}
+function getStyleByValue( value, styles ) {
+	for ( let style of styles ) {
+		if ( style.value === value ) {
+			return style;
+		}
+	}
+}
+
+// Handles converting removal of the attribute.
+// Returns `true` when handling was processed correctly and further conversion can be performed.
+//
+// @param {String} eventType Type of the event.
+// @param {module:image/imagestyle/imagestyleengine~ImageStyleFormat} style
+// @param {module:engine/view/element~Element} viewElement
+// @returns {Boolean} Whether the change was handled.
+function handleRemoval( eventType, style, viewElement ) {
+	if ( style && ( eventType == 'changeAttribute' || eventType == 'removeAttribute' ) ) {
+		viewElement.removeClass( style.className );
+
+		return true;
+	}
+
+	return false;
+}
+
+// Handles converting addition of the attribute.
+// Returns `true` when handling was processed correctly and further conversion can be performed.
+//
+// @param {String} eventType Type of the event.
+// @param {module:image/imagestyle/imagestyleengine~ImageStyleFormat} style
+// @param {module:engine/view/element~Element} viewElement
+// @returns {Boolean} Whether the change was handled.
+function handleAddition( evenType, style, viewElement ) {
+	if ( style && ( evenType == 'addAttribute' || evenType == 'changeAttribute' ) ) {
+		viewElement.addClass( style.className );
+
+		return true;
+	}
+
+	return false;
+}

+ 74 - 0
packages/ckeditor5-image/src/imagestyle/imagestyle.js

@@ -0,0 +1,74 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module image/imagestyle/imagestyle
+ */
+
+import Plugin from 'ckeditor5-core/src/plugin';
+import ImageStyleEngine from './imagestyleengine';
+import ButtonView from 'ckeditor5-ui/src/button/buttonview';
+
+/**
+ * The image style plugin.
+ *
+ * Uses {@link module:image/imagestyle/imagestyleengine~ImageStyleEngine}.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class ImageStyle extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ ImageStyleEngine ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const styles = this.editor.config.get( 'image.styles' );
+
+		for ( let style of styles ) {
+			this._createButton( style );
+		}
+
+		// Push buttons to default image toolbar if one exists.
+		const defaultImageToolbarConfig = this.editor.config.get( 'image.defaultToolbar' );
+
+		if ( defaultImageToolbarConfig ) {
+			styles.forEach( style => defaultImageToolbarConfig.push( style.name ) );
+		}
+	}
+
+	/**
+	 * Creates button for each style and stores it in editor's {@link module:ui/componentfactory~ComponentFactory ComponentFactory}.
+	 *
+	 * @private
+	 * @param {module:image/imagestyle/imagestyleengine~ImageStyleFormat} style
+	 */
+	_createButton( style ) {
+		const editor = this.editor;
+		const command = editor.commands.get( style.name );
+
+		editor.ui.componentFactory.add( style.name, ( locale ) => {
+			const view = new ButtonView( locale );
+
+			view.set( {
+				label: style.title,
+				icon: style.icon,
+				tooltip: true
+			} );
+
+			view.bind( 'isEnabled' ).to( command, 'isEnabled' );
+			view.bind( 'isOn' ).to( command, 'value' );
+
+			this.listenTo( view, 'execute', () => editor.execute( style.name ) );
+
+			return view;
+		} );
+	}
+}

+ 109 - 0
packages/ckeditor5-image/src/imagestyle/imagestylecommand.js

@@ -0,0 +1,109 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module image/imagestyle/imagestylecommand
+ */
+
+import Command from 'ckeditor5-core/src/command/command';
+import { isImage } from '../utils';
+
+/**
+ * The image style command. It is used to apply different image styles.
+ *
+ * @extends module:core/command/command~Command
+ */
+export default class ImageStyleCommand extends Command {
+	/**
+	 * Creates instance of the image style command. Each command instance is handling one style.
+	 *
+	 * @param {module:core/editor/editor~Editor} editor Editor instance.
+	 * @param {module:image/imagestyle/imagestyleengine~ImageStyleFormat} styles Style to apply by this command.
+	 */
+	constructor( editor, style ) {
+		super( editor );
+
+		/**
+		 * The current command value - `true` if style handled by the command is applied on currently selected image,
+		 * `false` otherwise.
+		 *
+		 * @readonly
+		 * @observable
+		 * @member {Boolean} #value
+		 */
+		this.set( 'value', false );
+
+		/**
+		 * Style handled by this command.
+		 *
+		 * @readonly
+		 * @member {module:image/imagestyle/imagestyleengine~ImageStyleFormat} #style
+		 */
+		this.style = style;
+
+		// Update current value and refresh state each time something change in model document.
+		this.listenTo( editor.document, 'changesDone', () => {
+			this._updateValue();
+			this.refreshState();
+		} );
+	}
+
+	/**
+	 * Updates command's value.
+	 *
+	 * @private
+	 */
+	_updateValue() {
+		const doc = this.editor.document;
+		const element = doc.selection.getSelectedElement();
+
+		if ( !element ) {
+			this.value = false;
+
+			return;
+		}
+
+		if ( this.style.value === null ) {
+			this.value = !element.hasAttribute( 'imageStyle' );
+		} else {
+			this.value = ( element.getAttribute( 'imageStyle' ) == this.style.value );
+		}
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	_checkEnabled() {
+		const element = this.editor.document.selection.getSelectedElement();
+
+		return isImage( element );
+	}
+
+	/**
+	 * Executes command.
+	 *
+	 * @protected
+	 * @param {Object} options
+	 * @param {module:engine/model/batch~Batch} [options.batch] Batch to collect all the change steps. New batch will be
+	 * created if this option is not set.
+	 */
+	_doExecute( options = {} ) {
+		// Stop if style is already applied.
+		if ( this.value ) {
+			return;
+		}
+
+		const editor = this.editor;
+		const doc = editor.document;
+		const selection = doc.selection;
+		const imageElement = selection.getSelectedElement();
+
+		doc.enqueueChanges( () => {
+			const batch = options.batch || doc.batch();
+
+			batch.setAttribute( imageElement, 'imageStyle', this.style.value );
+		} );
+	}
+}

+ 99 - 0
packages/ckeditor5-image/src/imagestyle/imagestyleengine.js

@@ -0,0 +1,99 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module image/imagestyle/imagestyleengine
+ */
+
+import Plugin from 'ckeditor5-core/src/plugin';
+import ImageStyleCommand from './imagestylecommand';
+import ImageEngine from '../imageengine';
+import { viewToModelStyleAttribute, modelToViewStyleAttribute } from './converters';
+import fullSizeIcon from 'ckeditor5-core/theme/icons/object-center.svg';
+import sideIcon from 'ckeditor5-core/theme/icons/object-right.svg';
+
+/**
+ * The image style engine plugin. Sets default configuration, creates converters and registers
+ * {@link module:image/imagestyle/imagestylecommand~ImageStyleCommand ImageStyleCommand}.
+ *
+ * @extends {module:core/plugin~Plugin}
+ */
+export default class ImageStyleEngine extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ ImageEngine ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+		const t = editor.t;
+		const doc = editor.document;
+		const schema = doc.schema;
+		const data = editor.data;
+		const editing = editor.editing;
+
+		// Define default configuration.
+		editor.config.define( 'image.styles', [
+			// This option is equal to situation when no style is applied.
+			{ name: 'imageStyleFull', title: t( 'Full size image' ), icon: fullSizeIcon, value: null },
+
+			// This represents side image.
+			{ name: 'imageStyleSide', title: t( 'Side image' ), icon: sideIcon, value: 'side', className: 'image-style-side' }
+		] );
+
+		// Get configuration.
+		const styles = editor.config.get( 'image.styles' );
+
+		// Allow imageStyle attribute in image.
+		// We could call it 'style' but https://github.com/ckeditor/ckeditor5-engine/issues/559.
+		schema.allow( { name: 'image', attributes: 'imageStyle', inside: '$root' } );
+
+		// Converters for imageStyle attribute from model to view.
+		const modelToViewConverter = modelToViewStyleAttribute( styles );
+		editing.modelToView.on( 'addAttribute:imageStyle:image', modelToViewConverter );
+		data.modelToView.on( 'addAttribute:imageStyle:image', modelToViewConverter );
+		editing.modelToView.on( 'changeAttribute:imageStyle:image', modelToViewConverter );
+		data.modelToView.on( 'changeAttribute:imageStyle:image', modelToViewConverter );
+		editing.modelToView.on( 'removeAttribute:imageStyle:image', modelToViewConverter );
+		data.modelToView.on( 'removeAttribute:imageStyle:image', modelToViewConverter );
+
+		// Converter for figure element from view to model.
+		data.viewToModel.on( 'element:figure', viewToModelStyleAttribute( styles ), { priority: 'low' } );
+
+		// Register separate command for each style.
+		for ( let style of styles ) {
+			editor.commands.set( style.name, new ImageStyleCommand( editor, style ) );
+		}
+	}
+}
+
+/**
+ * Image style format descriptor.
+ *
+ *	import fullIcon from 'path/to/icon.svg`;
+ *
+ *	const imageStyleFormat = {
+ *		name: 'fullSizeImage',
+ *		value: 'full',
+ *		icon: fullIcon,
+ *		title: `Full size image`,
+ *		class: `image-full-size`
+ *	}
+ *
+ * @typedef {Object} module:image/imagestyle/imagestyleengine~ImageStyleFormat
+ * @property {String} name Name of the style. It will be used to:
+ * * register {@link module:core/command/command~Command command} which will apply this style,
+ * * store style's button in editor's {@link module:ui/componentfactory~ComponentFactory ComponentFactory}.
+ * @property {String} value Value used to store this style in model attribute.
+ * When value is `null` style will be used as default one. Default style does not apply any CSS class to the view element.
+ * @property {String} icon SVG icon representation to use when creating style's button.
+ * @property {String} title Style's title.
+ * @property {String} className CSS class used to represent style in view.
+ */

+ 138 - 0
packages/ckeditor5-image/src/imagetoolbar.js

@@ -0,0 +1,138 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module image/imagetoolbar
+ */
+
+import Plugin from 'ckeditor5-core/src/plugin';
+import ToolbarView from 'ckeditor5-ui/src/toolbar/toolbarview';
+import BalloonPanelView from 'ckeditor5-ui/src/balloonpanel/balloonpanelview';
+import Template from 'ckeditor5-ui/src/template';
+import { isImageWidget } from './utils';
+import throttle from 'ckeditor5-utils/src/lib/lodash/throttle';
+import global from 'ckeditor5-utils/src/dom/global';
+
+const arrowVOffset = BalloonPanelView.arrowVerticalOffset;
+const positions = {
+	//	   [text range]
+	//	        ^
+	//	+-----------------+
+	//	|     Balloon     |
+	//	+-----------------+
+	south: ( targetRect, balloonRect ) => ( {
+		top: targetRect.bottom + arrowVOffset,
+		left: targetRect.left + targetRect.width / 2 - balloonRect.width / 2,
+		name: 's'
+	} ),
+
+	//	+-----------------+
+	//	|     Balloon     |
+	//	+-----------------+
+	//	        V
+	//	   [text range]
+	north: ( targetRect, balloonRect ) => ( {
+		top: targetRect.top - balloonRect.height - arrowVOffset,
+		left: targetRect.left + targetRect.width / 2 - balloonRect.width / 2,
+		name: 'n'
+	} )
+};
+
+/**
+ * Image toolbar class. Creates image toolbar placed inside balloon panel that is showed when image widget is selected.
+ * Toolbar components are created using editor's {@link module:ui/componentfactory~ComponentFactory ComponentFactory}
+ * based on {@link module:core/editor/editor~Editor#config configuration} stored under `image.toolbar`.
+ * Other plugins can add new components to the default toolbar configuration by pushing them to `image.defaultToolbar`
+ * configuration. Default configuration is used when `image.toolbar` config is not present.
+ *
+ * @extends module:core/plugin~Plugin.
+ */
+export default class ImageToolbar extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	constructor( editor ) {
+		super( editor );
+
+		editor.config.set( 'image.defaultToolbar', [] );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	afterInit() {
+		const editor = this.editor;
+		const toolbarConfig = editor.config.get( 'image.toolbar' ) || editor.config.get( 'image.defaultToolbar' );
+
+		// Don't add the toolbar if there is no configuration.
+		if ( !toolbarConfig.length ) {
+			return;
+		}
+
+		// Create a plain toolbar instance.
+		const toolbar = new ToolbarView();
+
+		// Create a BalloonPanelView instance.
+		const panel = new BalloonPanelView( editor.locale );
+
+		Template.extend( panel.template, {
+			attributes: {
+				class: [
+					'ck-toolbar__container'
+				]
+			}
+		} );
+
+		// Putting the toolbar inside of the balloon panel.
+		panel.content.add( toolbar );
+
+		return editor.ui.view.body.add( panel ).then( () => {
+			const editingView = editor.editing.view;
+			const promises = [];
+
+			for ( let name of toolbarConfig ) {
+				promises.push( toolbar.items.add( editor.ui.componentFactory.create( name ) ) );
+			}
+
+			// Let the focusTracker know about new focusable UI element.
+			editor.ui.focusTracker.add( panel.element );
+
+			// Hide the panel when editor loses focus but no the other way around.
+			panel.listenTo( editor.ui.focusTracker, 'change:isFocused', ( evt, name, is, was ) => {
+				if ( was && !is ) {
+					panel.hide();
+				}
+			} );
+
+			const attachToolbarCallback = throttle( attachToolbar, 100 );
+
+			// Check if the toolbar should be displayed each time view is rendered.
+			editor.listenTo( editingView, 'render', () => {
+				const selectedElement = editingView.selection.getSelectedElement();
+
+				if ( selectedElement && isImageWidget( selectedElement ) ) {
+					attachToolbar();
+
+					editor.ui.view.listenTo( global.window, 'scroll', attachToolbarCallback );
+					editor.ui.view.listenTo( global.window, 'resize', attachToolbarCallback );
+				} else {
+					panel.hide();
+
+					editor.ui.view.stopListening( global.window, 'scroll', attachToolbarCallback );
+					editor.ui.view.stopListening( global.window, 'resize', attachToolbarCallback );
+				}
+			}, { priority: 'low' } );
+
+			function attachToolbar() {
+				panel.attachTo( {
+					target: editingView.domConverter.viewRangeToDom( editingView.selection.getFirstRange() ),
+					positions: [ positions.north, positions.south ]
+				} );
+			}
+
+			return Promise.all( promises );
+		} );
+	}
+}

+ 12 - 0
packages/ckeditor5-image/src/utils.js

@@ -8,6 +8,7 @@
  */
  */
 
 
 import { widgetize, isWidget } from './widget/utils';
 import { widgetize, isWidget } from './widget/utils';
+import ModelElement from 'ckeditor5-engine/src/model/element';
 
 
 const imageSymbol = Symbol( 'isImage' );
 const imageSymbol = Symbol( 'isImage' );
 
 
@@ -34,3 +35,14 @@ export function toImageWidget( viewElement ) {
 export function isImageWidget( viewElement ) {
 export function isImageWidget( viewElement ) {
 	return !!viewElement.getCustomProperty( imageSymbol ) && isWidget( viewElement );
 	return !!viewElement.getCustomProperty( imageSymbol ) && isWidget( viewElement );
 }
 }
+
+/**
+ * Checks if provided modelElement is an instance of {@link module:engine/model/element~Element Element} and its name
+ * is `image`.
+ *
+ * @param {module:engine/model/element~Element} modelElement
+ * @returns {Boolean}
+ */
+export function isImage( modelElement ) {
+	return modelElement instanceof ModelElement && modelElement.name == 'image';
+}

+ 105 - 0
packages/ckeditor5-image/tests/imagestyle/imagestyle.js

@@ -0,0 +1,105 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import ClassicTestEditor from 'ckeditor5-core/tests/_utils/classictesteditor';
+import ImageToolbar from 'ckeditor5-image/src/imagetoolbar';
+import ImageStyle from 'ckeditor5-image/src/imagestyle/imagestyle';
+import ImageStyleEngine from 'ckeditor5-image/src/imagestyle/imagestyleengine';
+import ButtonView from 'ckeditor5-ui/src/button/buttonview';
+import global from 'ckeditor5-utils/src/dom/global';
+
+describe( 'ImageStyle', () => {
+	let editor;
+	const styles =  [
+		{ name: 'style 1', title: 'Style 1 title', icon: 'style1-icon', value: null },
+		{ name: 'style 2', title: 'Style 2 title', icon: 'style2-icon', value: 'style2', cssClass: 'style2-class' },
+		{ name: 'style 3', title: 'Style 3 title', icon: 'style3-icon', value: 'style3', cssClass: 'style3-class' }
+	];
+
+	beforeEach( () => {
+		const editorElement = global.document.createElement( 'div' );
+		global.document.body.appendChild( editorElement );
+
+		return ClassicTestEditor.create( editorElement, {
+			plugins: [ ImageStyle ],
+			image: {
+				styles
+			}
+		} )
+		.then( newEditor => {
+			editor = newEditor;
+		} );
+	} );
+
+	afterEach( () => {
+		return editor.destroy();
+	} );
+
+	it( 'should be loaded', () => {
+		expect( editor.plugins.get( ImageStyle ) ).to.be.instanceOf( ImageStyle );
+	} );
+
+	it( 'should load ImageStyleEngine plugin', () => {
+		expect( editor.plugins.get( ImageStyleEngine ) ).to.be.instanceOf( ImageStyleEngine );
+	} );
+
+	it( 'should register buttons for each style', () => {
+		const spy = sinon.spy( editor, 'execute' );
+
+		for ( let style of styles ) {
+			const command = editor.commands.get( style.name );
+			const buttonView =  editor.ui.componentFactory.create( style.name );
+
+			expect( buttonView ).to.be.instanceOf( ButtonView );
+			expect( buttonView.label ).to.equal( style.title );
+			expect( buttonView.icon ).to.equal( style.icon );
+
+			command.isEnabled = true;
+			expect( buttonView.isEnabled ).to.be.true;
+			command.isEnabled = false;
+			expect( buttonView.isEnabled ).to.be.false;
+
+			buttonView.fire( 'execute' );
+			sinon.assert.calledWithExactly( editor.execute, style.name );
+
+			spy.reset();
+		}
+	} );
+
+	it( 'should not add buttons to default image toolbar if image toolbar is not present', () => {
+		expect( editor.config.get( 'image.defaultToolbar' ) ).to.be.undefined;
+	} );
+
+	it( 'should add buttons to default image toolbar if toolbar is present', () => {
+		const editorElement = global.document.createElement( 'div' );
+		global.document.body.appendChild( editorElement );
+
+		return ClassicTestEditor.create( editorElement, {
+			plugins: [ ImageStyle, ImageToolbar ]
+		} )
+			.then( newEditor => {
+				expect( newEditor.config.get( 'image.defaultToolbar' ) ).to.eql( [ 'imageStyleFull', 'imageStyleSide' ] );
+
+				newEditor.destroy();
+			} );
+	} );
+
+	it( 'should not add buttons to image toolbar if configuration is present', () => {
+		const editorElement = global.document.createElement( 'div' );
+		global.document.body.appendChild( editorElement );
+
+		return ClassicTestEditor.create( editorElement, {
+			plugins: [ ImageStyle ],
+			image: {
+				styles,
+				toolbar: [ 'foo', 'bar' ]
+			}
+		} )
+		.then( newEditor => {
+			expect( newEditor.config.get( 'image.toolbar' ) ).to.eql( [ 'foo',  'bar' ] );
+			newEditor.destroy();
+		} );
+	} );
+} );

+ 108 - 0
packages/ckeditor5-image/tests/imagestyle/imagestylecommand.js

@@ -0,0 +1,108 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import ModelTestEditor from 'ckeditor5-core/tests/_utils/modeltesteditor';
+import ImageStyleCommand from 'ckeditor5-image/src/imagestyle/imagestylecommand';
+import { setData, getData } from 'ckeditor5-engine/src/dev-utils/model';
+
+describe( 'ImageStyleCommand', () => {
+	const defaultStyle = { name: 'defaultStyle', title: 'foo bar', icon: 'icon-1', value: null };
+	const otherStyle = { name: 'otherStyle', title: 'baz', icon: 'icon-2', value: 'other', className: 'other-class-name' };
+
+	let document, defaultStyleCommand, otherStyleCommand;
+
+	beforeEach( () => {
+		return ModelTestEditor.create()
+			.then( newEditor => {
+				document = newEditor.document;
+				defaultStyleCommand = new ImageStyleCommand( newEditor, defaultStyle );
+				otherStyleCommand = new ImageStyleCommand( newEditor, otherStyle );
+
+				document.schema.registerItem( 'p', '$block' );
+
+				document.schema.registerItem( 'image' );
+				document.schema.objects.add( 'image' );
+				document.schema.allow( { name: 'image', inside: '$root' } );
+				document.schema.allow( { name: 'image', inside: '$root', attributes: [ 'imageStyle' ] } );
+			} );
+	} );
+
+	it( 'command value should be false if no image is selected', () => {
+		setData( document, '[]<image></image>' );
+
+		expect( defaultStyleCommand.value ).to.be.false;
+		expect( otherStyleCommand.value ).to.be.false;
+	} );
+
+	it( 'should match default style if no imageStyle attribute is present', () => {
+		setData( document, '[<image></image>]' );
+
+		expect( defaultStyleCommand.value ).to.be.true;
+		expect( otherStyleCommand.value ).to.be.false;
+	} );
+
+	it( 'proper command should have true value when imageStyle attribute is present', () => {
+		setData( document, '[<image imageStyle="other"></image>]' );
+
+		expect( defaultStyleCommand.value ).to.be.false;
+		expect( otherStyleCommand.value ).to.be.true;
+	} );
+
+	it( 'should have false value if style does not match', () => {
+		setData( document, '[<image imageStyle="foo"></image>]' );
+
+		expect( defaultStyleCommand.value ).to.be.false;
+		expect( otherStyleCommand.value ).to.be.false;
+	} );
+
+	it( 'should set proper value when executed', () => {
+		setData( document, '[<image></image>]' );
+
+		otherStyleCommand._doExecute();
+
+		expect( getData( document ) ).to.equal( '[<image imageStyle="other"></image>]' );
+	} );
+
+	it( 'should do nothing when attribute already present', () => {
+		setData( document, '[<image imageStyle="other"></image>]' );
+
+		otherStyleCommand._doExecute();
+
+		expect( getData( document ) ).to.equal( '[<image imageStyle="other"></image>]' );
+	} );
+
+	it( 'should allow to provide batch instance', () => {
+		const batch = document.batch();
+		const spy = sinon.spy( batch, 'setAttribute' );
+
+		setData( document, '[<image></image>]' );
+
+		otherStyleCommand._doExecute( { batch } );
+
+		expect( getData( document ) ).to.equal( '[<image imageStyle="other"></image>]' );
+		sinon.assert.calledOnce( spy );
+	} );
+
+	it( 'should be enabled on image element', () => {
+		setData( document, '[<image></image>]' );
+
+		expect( defaultStyleCommand.isEnabled ).to.be.true;
+		expect( otherStyleCommand.isEnabled ).to.be.true;
+	} );
+
+	it( 'should be disabled when not placed on image', () => {
+		setData( document, '[<p></p>]' );
+
+		expect( defaultStyleCommand.isEnabled ).to.be.false;
+		expect( otherStyleCommand.isEnabled ).to.be.false;
+	} );
+
+	it( 'should be disabled when not placed directly on image', () => {
+		setData( document, '[<p></p><image></image>]' );
+
+		expect( defaultStyleCommand.isEnabled ).to.be.false;
+		expect( otherStyleCommand.isEnabled ).to.be.false;
+	} );
+} );

+ 208 - 0
packages/ckeditor5-image/tests/imagestyle/imagestyleengine.js

@@ -0,0 +1,208 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import VirtualTestEditor from 'ckeditor5-core/tests/_utils/virtualtesteditor';
+import ImageStyleEngine from 'ckeditor5-image/src/imagestyle/imagestyleengine';
+import ImageEngine from 'ckeditor5-image/src/imageengine';
+import ImageStyleCommand from 'ckeditor5-image/src/imagestyle/imagestylecommand';
+import { getData as getModelData, setData as setModelData } from 'ckeditor5-engine/src/dev-utils/model';
+import { getData as getViewData } from 'ckeditor5-engine/src/dev-utils/view';
+
+describe( 'ImageStyleEngine', () => {
+	let editor, document, viewDocument;
+
+	beforeEach( () => {
+		return VirtualTestEditor.create( {
+			plugins: [ ImageStyleEngine ],
+			image: {
+				styles: [
+					{ name: 'fullStyle', title: 'foo', icon: 'object-center', value: null },
+					{ name: 'sideStyle', title: 'bar', icon: 'object-right', value: 'side', className: 'side-class' },
+					{ name: 'dummyStyle', title: 'baz', icon: 'object-dummy', value: 'dummy', className: 'dummy-class' }
+				]
+			}
+		} )
+			.then( newEditor => {
+				editor = newEditor;
+				document = editor.document;
+				viewDocument = editor.editing.view;
+			} );
+	} );
+
+	it( 'should be loaded', () => {
+		expect( editor.plugins.get( ImageStyleEngine ) ).to.be.instanceOf( ImageStyleEngine );
+	} );
+
+	it( 'should load image engine', () => {
+		expect( editor.plugins.get( ImageEngine ) ).to.be.instanceOf( ImageEngine );
+	} );
+
+	it( 'should set schema rules for image style', () => {
+		const schema = document.schema;
+
+		expect( schema.check( { name: 'image', attributes: [ 'imageStyle', 'src' ], inside: '$root' } ) ).to.be.true;
+	} );
+
+	it( 'should register separate command for each style', () => {
+		expect( editor.commands.has( 'fullStyle' ) ).to.be.true;
+		expect( editor.commands.has( 'sideStyle' ) ).to.be.true;
+		expect( editor.commands.has( 'dummyStyle' ) ).to.be.true;
+
+		expect( editor.commands.get( 'fullStyle' ) ).to.be.instanceOf( ImageStyleCommand );
+		expect( editor.commands.get( 'sideStyle' ) ).to.be.instanceOf( ImageStyleCommand );
+		expect( editor.commands.get( 'dummyStyle' ) ).to.be.instanceOf( ImageStyleCommand );
+	} );
+
+	it( 'should convert from view to model', () => {
+		editor.setData( '<figure class="image side-class"><img src="foo.png" /></figure>' );
+
+		expect( getModelData( document, { withoutSelection: true } ) ).to.equal( '<image imageStyle="side" src="foo.png"></image>' );
+	} );
+
+	it( 'should not convert from view to model if class is not defined', () => {
+		editor.setData( '<figure class="image foo-bar"><img src="foo.png" /></figure>' );
+
+		expect( getModelData( document, { withoutSelection: true } ) ).to.equal( '<image src="foo.png"></image>' );
+	} );
+
+	it( 'should not convert from view to model when not in image figure', () => {
+		editor.setData( '<figure class="side-class"></figure>'  );
+
+		expect( getModelData( document, { withoutSelection: true } ) ).to.equal( '' );
+	} );
+
+	it( 'should not convert from view to model if schema prevents it', () => {
+		document.schema.disallow( { name: 'image', attributes: 'imageStyle' } );
+		editor.setData( '<figure class="image side-class"><img src="foo.png" /></figure>' );
+
+		expect( getModelData( document, { withoutSelection: true } ) ).to.equal( '<image src="foo.png"></image>' );
+	} );
+
+	it( 'should convert model to view: adding attribute', () => {
+		setModelData( document, '<image src="foo.png"></image>' );
+		const image = document.getRoot().getChild( 0 );
+		const batch = document.batch();
+
+		document.enqueueChanges( () => {
+			batch.setAttribute( image, 'imageStyle', 'side' );
+		} );
+
+		expect( editor.getData() ).to.equal( '<figure class="image side-class"><img src="foo.png"></figure>' );
+	} );
+
+	it( 'should convert model to view: removing attribute', () => {
+		setModelData( document, '<image src="foo.png" imageStyle="side"></image>' );
+		const image = document.getRoot().getChild( 0 );
+		const batch = document.batch();
+
+		document.enqueueChanges( () => {
+			batch.setAttribute( image, 'imageStyle', null );
+		} );
+
+		expect( editor.getData() ).to.equal( '<figure class="image"><img src="foo.png"></figure>' );
+	} );
+
+	it( 'should convert model to view: change attribute', () => {
+		setModelData( document, '<image src="foo.png" imageStyle="dummy"></image>' );
+		const image = document.getRoot().getChild( 0 );
+		const batch = document.batch();
+
+		document.enqueueChanges( () => {
+			batch.setAttribute( image, 'imageStyle', 'side' );
+		} );
+
+		expect( editor.getData() ).to.equal( '<figure class="image side-class"><img src="foo.png"></figure>' );
+	} );
+
+	it( 'should not convert from model to view if already consumed: adding attribute', () => {
+		editor.editing.modelToView.on( 'addAttribute:imageStyle', ( evt, data, consumable ) => {
+			consumable.consume( data.item, 'addAttribute:imageStyle' );
+		}, { priority: 'high' } );
+
+		setModelData( document, '<image src="foo.png"></image>' );
+		const image = document.getRoot().getChild( 0 );
+		const batch = document.batch();
+
+		document.enqueueChanges( () => {
+			batch.setAttribute( image, 'imageStyle', 'side' );
+		} );
+
+		expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal(
+			'<figure class="image ck-widget" contenteditable="false"><img src="foo.png"></img></figure>'
+		);
+	} );
+
+	it( 'should not convert from model to view if already consumed: removing attribute', () => {
+		editor.editing.modelToView.on( 'removeAttribute:imageStyle', ( evt, data, consumable ) => {
+			consumable.consume( data.item, 'removeAttribute:imageStyle' );
+		}, { priority: 'high' } );
+
+		setModelData( document, '<image src="foo.png" imageStyle="side"></image>' );
+		const image = document.getRoot().getChild( 0 );
+		const batch = document.batch();
+
+		document.enqueueChanges( () => {
+			batch.setAttribute( image, 'imageStyle', null );
+		} );
+
+		expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal(
+			'<figure class="image ck-widget side-class" contenteditable="false"><img src="foo.png"></img></figure>'
+		);
+	} );
+
+	it( 'should not convert from model to view if already consumed: change attribute', () => {
+		editor.editing.modelToView.on( 'changeAttribute:imageStyle', ( evt, data, consumable ) => {
+			consumable.consume( data.item, 'changeAttribute:imageStyle' );
+		}, { priority: 'high' } );
+
+		setModelData( document, '<image src="foo.png" imageStyle="dummy"></image>' );
+		const image = document.getRoot().getChild( 0 );
+		const batch = document.batch();
+
+		document.enqueueChanges( () => {
+			batch.setAttribute( image, 'imageStyle', 'side' );
+		} );
+
+		expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal(
+			'<figure class="image ck-widget dummy-class" contenteditable="false"><img src="foo.png"></img></figure>'
+		);
+	} );
+
+	it( 'should not convert from model to view if style is not present: adding attribute', () => {
+		setModelData( document, '<image src="foo.png"></image>' );
+		const image = document.getRoot().getChild( 0 );
+		const batch = document.batch();
+
+		document.enqueueChanges( () => {
+			batch.setAttribute( image, 'imageStyle', 'foo' );
+		} );
+
+		expect( editor.getData() ).to.equal( '<figure class="image"><img src="foo.png"></figure>' );
+	} );
+
+	it( 'should not convert from model to view if style is not present: change attribute', () => {
+		setModelData( document, '<image src="foo.png" imageStyle="dummy"></image>' );
+		const image = document.getRoot().getChild( 0 );
+		const batch = document.batch();
+
+		document.enqueueChanges( () => {
+			batch.setAttribute( image, 'imageStyle', 'foo' );
+		} );
+
+		expect( editor.getData() ).to.equal( '<figure class="image"><img src="foo.png"></figure>' );
+	} );
+
+	it( 'should not convert from model to view if style is not present: remove attribute', () => {
+		setModelData( document, '<image src="foo.png" imageStyle="foo"></image>' );
+		const image = document.getRoot().getChild( 0 );
+		const batch = document.batch();
+
+		document.enqueueChanges( () => {
+			batch.setAttribute( image, 'imageStyle', null );
+		} );
+
+		expect( editor.getData() ).to.equal( '<figure class="image"><img src="foo.png"></figure>' );
+	} );
+} );

+ 200 - 0
packages/ckeditor5-image/tests/imagetoolbar.js

@@ -0,0 +1,200 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global Event */
+
+import ClassicEditor from 'ckeditor5-editor-classic/src/classic';
+import ImageToolbar from 'ckeditor5-image/src/imagetoolbar';
+import Image from 'ckeditor5-image/src/image';
+import global from 'ckeditor5-utils/src/dom/global';
+import BalloonPanelView from 'ckeditor5-ui/src/balloonpanel/balloonpanelview';
+import Plugin from 'ckeditor5-core/src/plugin';
+import ButtonView from 'ckeditor5-ui/src/button/buttonview';
+import { setData } from 'ckeditor5-engine/src/dev-utils/model';
+
+describe( 'ImageToolbar', () => {
+	let editor, button, editingView, doc, panel;
+
+	beforeEach( () => {
+		const editorElement = global.document.createElement( 'div' );
+		global.document.body.appendChild( editorElement );
+
+		return ClassicEditor.create( editorElement, {
+			plugins: [ Image, ImageToolbar, FakeButton ],
+			image: {
+				toolbar: [ 'fake_button' ]
+			}
+		} )
+		.then( newEditor => {
+			editor = newEditor;
+			editingView = editor.editing.view;
+			doc = editor.document;
+			panel = getBalloonPanelView( editor.ui.view.body );
+		} );
+	} );
+
+	afterEach( () => {
+		return editor.destroy();
+	} );
+
+	it( 'should be loaded', () => {
+		expect( editor.plugins.get( ImageToolbar ) ).to.be.instanceOf( ImageToolbar );
+	} );
+
+	it( 'should initialize image.defaultToolbar to an empty array', () => {
+		expect( editor.config.get( 'image.defaultToolbar' ) ).to.eql( [] );
+	} );
+
+	it( 'should not initialize if there is no configuration', () => {
+		const editorElement = global.document.createElement( 'div' );
+		global.document.body.appendChild( editorElement );
+
+		return ClassicEditor.create( editorElement, {
+			plugins: [ ImageToolbar ],
+		} )
+			.then( newEditor => {
+				const viewBody = newEditor.ui.view.body;
+				expect( getBalloonPanelView( viewBody ) ).to.be.undefined;
+
+				newEditor.destroy();
+			} );
+	} );
+
+	it( 'should allow other plugins to alter default config', () => {
+		const editorElement = global.document.createElement( 'div' );
+		global.document.body.appendChild( editorElement );
+
+		return ClassicEditor.create( editorElement, {
+			plugins: [ ImageToolbar, FakeButton, AlterDefaultConfig ]
+		} )
+			.then( newEditor => {
+				const panel = getBalloonPanelView( newEditor.ui.view.body );
+				const toolbar = panel.content.get( 0 );
+				const button = toolbar.items.get( 0 );
+
+				expect( newEditor.config.get( 'image.defaultToolbar' ) ).to.eql( [ 'fake_button' ] );
+				expect( button.label ).to.equal( 'fake button' );
+
+				newEditor.destroy();
+			} );
+	} );
+
+	it( 'should add BalloonPanelView to view body', () => {
+		expect( panel ).to.be.instanceOf( BalloonPanelView );
+	} );
+
+	it( 'should attach toolbar when image is selected', () => {
+		const spy = sinon.spy( panel, 'attachTo' );
+		setData( doc, '[<image src=""></image>]' );
+
+		testPanelAttach( spy );
+	} );
+
+	it( 'should calculate panel position on scroll event', () => {
+		setData( doc, '[<image src=""></image>]' );
+		const spy = sinon.spy( panel, 'attachTo' );
+
+		global.window.dispatchEvent( new Event( 'scroll' ) );
+
+		testPanelAttach( spy );
+	} );
+
+	it( 'should calculate panel position on resize event', () => {
+		setData( doc, '[<image src=""></image>]' );
+		const spy = sinon.spy( panel, 'attachTo' );
+
+		global.window.dispatchEvent( new Event( 'resize' ) );
+
+		testPanelAttach( spy );
+	} );
+
+	it( 'should not calculate panel position on scroll if no image is selected', () => {
+		setData( doc, '<image src=""></image>' );
+		const spy = sinon.spy( panel, 'attachTo' );
+
+		global.window.dispatchEvent( new Event( 'scroll' ) );
+
+		sinon.assert.notCalled( spy );
+	} );
+
+	it( 'should not calculate panel position on resize if no image is selected', () => {
+		setData( doc, '<image src=""></image>' );
+		const spy = sinon.spy( panel, 'attachTo' );
+
+		global.window.dispatchEvent( new Event( 'resize' ) );
+
+		sinon.assert.notCalled( spy );
+	} );
+
+	it( 'should hide the panel when editor looses focus', () => {
+		setData( doc, '[<image src=""></image>]' );
+		editor.ui.focusTracker.isFocused = true;
+		const spy = sinon.spy( panel, 'hide' );
+		editor.ui.focusTracker.isFocused = false;
+
+		sinon.assert.calledOnce( spy );
+	} );
+
+	// Returns BalloonPanelView from provided collection.
+	function getBalloonPanelView( viewCollection ) {
+		return viewCollection.find( item => item instanceof BalloonPanelView );
+	}
+
+	// Tests if panel.attachTo() was called correctly.
+	function testPanelAttach( spy ) {
+		const domRange = editor.editing.view.domConverter.viewRangeToDom( editingView.selection.getFirstRange() );
+
+		sinon.assert.calledOnce( spy );
+		const options = spy.firstCall.args[ 0 ];
+
+		// Check if proper range was used.
+		expect( options.target.startContainer ).to.equal( domRange.startContainer );
+		expect( options.target.startOffset ).to.equal( domRange.startOffset );
+		expect( options.target.endContainer ).to.equal( domRange.endContainer );
+		expect( options.target.endOffset ).to.equal( domRange.endOffset );
+
+		// Check if north/south calculation is correct.
+		const [ north, south ] = options.positions;
+		const targetRect = { top: 10, left: 20, width: 200, height: 100, bottom: 110, right: 220 };
+		const balloonRect = { width: 50, height: 20 };
+
+		const northPosition = north( targetRect, balloonRect );
+		expect( northPosition.name ).to.equal( 'n' );
+		expect( northPosition.top ).to.equal( targetRect.top - balloonRect.height - BalloonPanelView.arrowVerticalOffset );
+		expect( northPosition.left ).to.equal( targetRect.left + targetRect.width / 2 - balloonRect.width / 2 );
+
+		const southPosition = south( targetRect, balloonRect );
+		expect( southPosition.name ).to.equal( 's' );
+		expect( southPosition.top ).to.equal( targetRect.bottom + BalloonPanelView.arrowVerticalOffset );
+		expect( southPosition.left ).to.equal( targetRect.left + targetRect.width / 2 - balloonRect.width / 2 );
+	}
+
+	// Plugin that adds fake_button to editor's component factory.
+	class FakeButton extends Plugin {
+		init() {
+			this.editor.ui.componentFactory.add( 'fake_button', ( locale ) => {
+				const view = new ButtonView( locale );
+
+				view.set( {
+					label: 'fake button'
+				} );
+
+				button = view;
+
+				return view;
+			} );
+		}
+	}
+
+	class AlterDefaultConfig extends Plugin {
+		init() {
+			const defaultImageToolbarConfig = this.editor.config.get( 'image.defaultToolbar' );
+
+			if ( defaultImageToolbarConfig ) {
+				defaultImageToolbarConfig.push( 'fake_button' );
+			}
+		}
+	}
+} );

+ 1 - 1
packages/ckeditor5-image/tests/manual/image.html

@@ -1,6 +1,6 @@
 <div id="editor">
 <div id="editor">
 	<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>
 	<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>
-	<figure class="image">
+	<figure class="image image-style-side">
 		<img src="logo.png" alt="" />
 		<img src="logo.png" alt="" />
 	</figure>
 	</figure>
 	<figure class="image">
 	<figure class="image">

+ 10 - 0
packages/ckeditor5-image/tests/manual/imagestyle.html

@@ -0,0 +1,10 @@
+<div id="editor">
+	<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>
+	<figure class="image">
+		<img src="logo.png" alt="" />
+	</figure>
+	<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. 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. 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. 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>
+	<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>
+	<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>
+	<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>

+ 28 - 0
packages/ckeditor5-image/tests/manual/imagestyle.js

@@ -0,0 +1,28 @@
+/**
+ * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global document, console, window */
+
+import ClassicEditor from 'ckeditor5-editor-classic/src/classic';
+import EnterPlugin from 'ckeditor5-enter/src/enter';
+import TypingPlugin from 'ckeditor5-typing/src/typing';
+import ParagraphPlugin from 'ckeditor5-paragraph/src/paragraph';
+import HeadingPlugin from 'ckeditor5-heading/src/heading';
+import ImagePlugin from 'ckeditor5-image/src/image';
+import UndoPlugin from 'ckeditor5-undo/src/undo';
+import ClipboardPlugin from 'ckeditor5-clipboard/src/clipboard';
+import ImageStyle from 'ckeditor5-image/src/imagestyle/imagestyle';
+import ImageToolbar from 'ckeditor5-image/src/imagetoolbar';
+
+ClassicEditor.create( document.querySelector( '#editor' ), {
+	plugins: [ ImageToolbar, EnterPlugin, TypingPlugin, ParagraphPlugin, HeadingPlugin, ImagePlugin, UndoPlugin, ClipboardPlugin, ImageStyle ],
+	toolbar: [ 'headings', 'undo', 'redo' ]
+} )
+	.then( editor => {
+		window.editor = editor;
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );

+ 6 - 0
packages/ckeditor5-image/tests/manual/imagestyle.md

@@ -0,0 +1,6 @@
+## ImageStyle feature
+
+* Click on image - toolbar with icons should appear. "Full size image" icon should be selected.
+* Click on "Side image" icon. Image should be aligned to right.
+* Click on "Full size image" icon. Image should be back to its original state.
+* When image toolbar is visible, resize the browser window and scroll - check if toolbar is placed in proper position.

+ 21 - 1
packages/ckeditor5-image/tests/utils.js

@@ -4,7 +4,8 @@
  */
  */
 
 
 import ViewElement from 'ckeditor5-engine/src/view/element';
 import ViewElement from 'ckeditor5-engine/src/view/element';
-import { toImageWidget, isImageWidget } from 'ckeditor5-image/src/utils';
+import ModelElement from 'ckeditor5-engine/src/model/element';
+import { toImageWidget, isImageWidget, isImage } from 'ckeditor5-image/src/utils';
 import { isWidget } from 'ckeditor5-image/src/widget/utils';
 import { isWidget } from 'ckeditor5-image/src/widget/utils';
 
 
 describe( 'image widget utils', () => {
 describe( 'image widget utils', () => {
@@ -30,4 +31,23 @@ describe( 'image widget utils', () => {
 			expect( isImageWidget( new ViewElement( 'p' ) ) ).to.be.false;
 			expect( isImageWidget( new ViewElement( 'p' ) ) ).to.be.false;
 		} );
 		} );
 	} );
 	} );
+
+	describe( 'isImage', () => {
+		it( 'should return true for image element', () => {
+			const image = new ModelElement( 'image' );
+
+			expect( isImage( image ) ).to.be.true;
+		} );
+
+		it( 'should return true false for different elements', () => {
+			const image = new ModelElement( 'foo' );
+
+			expect( isImage( image ) ).to.be.false;
+		} );
+
+		it( 'should return true false for null and undefined', () => {
+			expect( isImage( null ) ).to.be.false;
+			expect( isImage( undefined ) ).to.be.false;
+		} );
+	} );
 } );
 } );

+ 8 - 0
packages/ckeditor5-image/theme/theme.scss

@@ -22,4 +22,12 @@
 // Image widget's styles.
 // Image widget's styles.
 .ck-widget.image {
 .ck-widget.image {
 	text-align: center;
 	text-align: center;
+	clear: both;
+
+	&.image-style-side {
+		display: inline-block;
+		float: right;
+		margin-left: 0.8em;
+	}
 }
 }
+