Przeglądaj źródła

Defined ImageStyleEngine#imageStyles() getter which normalises config.image.styles with translations, ImageStyleEngine.defaultStyles extension and ImageStyleEngine.defaultIcons support.

Aleksander Nowodzinski 8 lat temu
rodzic
commit
d2008822a5

+ 2 - 1
packages/ckeditor5-image/src/imagestyle.js

@@ -37,7 +37,8 @@ export default class ImageStyle extends Plugin {
 	 * @inheritDoc
 	 * @inheritDoc
 	 */
 	 */
 	init() {
 	init() {
-		const styles = this.editor.config.get( 'image.styles' );
+		const editor = this.editor;
+		const styles = editor.plugins.get( ImageStyleEngine ).imageStyles;
 
 
 		for ( const style of styles ) {
 		for ( const style of styles ) {
 			this._createButton( style );
 			this._createButton( style );

+ 198 - 12
packages/ckeditor5-image/src/imagestyle/imagestyleengine.js

@@ -11,8 +11,11 @@ import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import ImageStyleCommand from './imagestylecommand';
 import ImageStyleCommand from './imagestylecommand';
 import ImageEngine from '../image/imageengine';
 import ImageEngine from '../image/imageengine';
 import { viewToModelStyleAttribute, modelToViewStyleAttribute } from './converters';
 import { viewToModelStyleAttribute, modelToViewStyleAttribute } from './converters';
-import fullSizeIcon from '@ckeditor/ckeditor5-core/theme/icons/object-center.svg';
-import sideIcon from '@ckeditor/ckeditor5-core/theme/icons/object-right.svg';
+import log from '@ckeditor/ckeditor5-utils/src/log';
+
+import leftIcon from '@ckeditor/ckeditor5-core/theme/icons/object-left.svg';
+import centerIcon from '@ckeditor/ckeditor5-core/theme/icons/object-center.svg';
+import rightIcon from '@ckeditor/ckeditor5-core/theme/icons/object-right.svg';
 
 
 /**
 /**
  * The image style engine plugin. It sets the default configuration, creates converters and registers
  * The image style engine plugin. It sets the default configuration, creates converters and registers
@@ -33,23 +36,16 @@ export default class ImageStyleEngine extends Plugin {
 	 */
 	 */
 	init() {
 	init() {
 		const editor = this.editor;
 		const editor = this.editor;
-		const t = editor.t;
 		const doc = editor.document;
 		const doc = editor.document;
 		const schema = doc.schema;
 		const schema = doc.schema;
 		const data = editor.data;
 		const data = editor.data;
 		const editing = editor.editing;
 		const editing = editor.editing;
 
 
 		// Define default configuration.
 		// 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' }
-		] );
+		editor.config.define( 'image.styles', [ 'imageStyleFull', 'imageStyleSide' ] );
 
 
 		// Get configuration.
 		// Get configuration.
-		const styles = editor.config.get( 'image.styles' );
+		const styles = this.imageStyles;
 
 
 		// Allow imageStyle attribute in image.
 		// Allow imageStyle attribute in image.
 		// We could call it 'style' but https://github.com/ckeditor/ckeditor5-engine/issues/559.
 		// We could call it 'style' but https://github.com/ckeditor/ckeditor5-engine/issues/559.
@@ -72,6 +68,194 @@ export default class ImageStyleEngine extends Plugin {
 			editor.commands.add( style.name, new ImageStyleCommand( editor, style ) );
 			editor.commands.add( style.name, new ImageStyleCommand( editor, style ) );
 		}
 		}
 	}
 	}
+
+	/**
+	 * Returns {@link module:image/image~ImageConfig#styles} array with items normalized in the
+	 * {@link module:image/imagestyle/imagestyleengine~ImageStyleFormat} format, translated
+	 * `title` and a complete `icon` markup for each style.
+	 *
+	 * @readonly
+	 * @type {Array.<module:image/imagestyle/imagestyleengine~ImageStyleFormat>}
+	 */
+	get imageStyles() {
+		// Return cached value if there is one to improve the performance.
+		if ( this._cachedImageStyles ) {
+			return this._cachedImageStyles;
+		}
+
+		const styles = [];
+		const editor = this.editor;
+		const titles = this._localizedStyleTitles;
+		const configuredStyles = editor.config.get( 'image.styles' );
+
+		for ( let style of configuredStyles ) {
+			style = normalizeStyle( style );
+
+			// Localize the titles of the styles, if a title corresponds with
+			// a localized default provided by the plugin.
+			if ( titles[ style.title ] ) {
+				style.title = titles[ style.title ];
+			}
+
+			// Don't override the user-defined styles array, clone it instead.
+			styles.push( style );
+		}
+
+		return ( this._cachedImageStyles = styles );
+	}
+
+	/**
+	 * Returns the default localized style titles provided by the plugin e.g. ready to
+	 * use in the {@link #imageStyles}.
+	 *
+	 * @readonly
+	 * @private
+	 * @type {Object.<String,String>}
+	 */
+	get _localizedStyleTitles() {
+		const t = this.editor.t;
+
+		return {
+			'Full size image': t( 'Full size image' ),
+			'Side image': t( 'Side image' ),
+			'Left aligned image': t( 'Left aligned image' ),
+			'Centered image': t( 'Centered image' ),
+			'Right aligned image': t( 'Right aligned image' ),
+		};
+	}
+}
+
+/**
+ * Default image styles provided by the plugin, which can be referred in the
+ * {@link module:image/image~ImageConfig#styles} config.
+ *
+ * Among them, 2 default semantic content styles are available:
+ *
+ * * `imageStyleFull` is a full–width image without any CSS class,
+ * * `imageStyleSide` is a side image styled with the `image-style-side` CSS class
+ *
+ * There are also 3 styles focused on formatting:
+ *
+ * * `imageStyleLeft` aligns the image to the left using the `image-style-align-left` class,
+ * * `imageStyleCenter` centers the image to the left using the `image-style-align-center` class,
+ * * `imageStyleRight` aligns the image to the right using the `image-style-align-right` class,
+ *
+ * @member {Object.<String,Object>}
+ */
+ImageStyleEngine.defaultStyles = {
+	// This option is equal to situation when no style is applied.
+	imageStyleFull: {
+		name: 'imageStyleFull',
+		title: 'Full size image',
+		icon: centerIcon,
+		value: null
+	},
+
+	// This represents side image.
+	imageStyleSide: {
+		name: 'imageStyleSide',
+		title: 'Side image',
+		icon: rightIcon,
+		value: 'side',
+		className: 'image-style-side'
+	},
+
+	// This style represents an imaged aligned to the left.
+	imageStyleLeft: {
+		name: 'imageStyleLeft',
+		title: 'Left aligned image',
+		icon: leftIcon,
+		value: 'left',
+		className: 'image-style-align-left'
+	},
+
+	// This style represents a centered imaged.
+	imageStyleCenter: {
+		name: 'imageStyleCenter',
+		title: 'Centered image',
+		icon: centerIcon,
+		value: 'side',
+		className: 'image-style-align-center'
+	},
+
+	// This style represents an imaged aligned to the right.
+	imageStyleRight: {
+		name: 'imageStyleRight',
+		title: 'Right aligned image',
+		icon: rightIcon,
+		value: 'right',
+		className: 'image-style-align-right'
+	}
+};
+
+/**
+ * Default image style icons provided by the plugin, which can be referred in the
+ * {@link module:image/image~ImageConfig#styles} config.
+ *
+ * There are 3 icons available: `'left'`, `'center'` and `'right'`.
+ *
+ * @member {Object.<String, String>}
+ */
+ImageStyleEngine.defaultIcons = {
+	left: leftIcon,
+	right: rightIcon,
+	center: centerIcon,
+};
+
+// Normalizes an image style provided in the {@link module:image/image~ImageConfig#styles}
+// and returns it in a {@link module:image/imagestyle/imagestyleengine~ImageStyleFormat}.
+//
+// @private
+// @param {Object} style
+// @returns {@link module:image/imagestyle/imagestyleengine~ImageStyleFormat}
+function normalizeStyle( style ) {
+	const defaultStyles = ImageStyleEngine.defaultStyles;
+	const defaultIcons = ImageStyleEngine.defaultIcons;
+
+	// Just the name of the style has been passed.
+	if ( typeof style == 'string' ) {
+		// If it's one of the defaults, just use it.
+		// Clone the style to avoid overriding defaults.
+		if ( defaultStyles[ style ] ) {
+			style = Object.assign( {}, defaultStyles[ style ] );
+		}
+		// If it's just a name but non of the defaults, warn because probably it's a mistake.
+		// A which has just a name makes a little sense for the plugin.
+		else {
+			log.warn(
+				'image-style-not-found: There is no such image style of given name.',
+				{ name: style } );
+
+			// Normalize the style anyway to prevent errors.
+			style = {
+				name: style
+			};
+		}
+	}
+
+	// If an object style has been passed and if the name matches one of the defaults,
+	// extend it with defaults – the user wants to customize a default style.
+	// Note: Don't override the user–defined style object, clone it instead.
+	else if ( defaultStyles[ style.name ] ) {
+		const defaultStyle = defaultStyles[ style.name ];
+		const extendedStyle = Object.assign( {}, style );
+
+		for ( const prop in defaultStyle ) {
+			if ( !style.hasOwnProperty( prop ) ) {
+				extendedStyle[ prop ] = defaultStyle[ prop ];
+			}
+		}
+
+		style = extendedStyle;
+	}
+
+	// If an icon is defined as a string and correspond with a name
+	// in default icons, use the default icon provided by the plugin.
+	if ( typeof style.icon == 'string' && defaultIcons[ style.icon ] ) {
+		style.icon = defaultIcons[ style.icon ];
+	}
+
+	return style;
 }
 }
 
 
 /**
 /**
@@ -93,7 +277,9 @@ export default class ImageStyleEngine extends Plugin {
  * * store the style's button in the editor {@link module:ui/componentfactory~ComponentFactory}.
  * * store the style's button in the editor {@link module:ui/componentfactory~ComponentFactory}.
  * @property {String} value A value used to store this style in the model attribute.
  * @property {String} value A value used to store this style in the model attribute.
  * When the value is `null`, the style will be used as the default one. A default style does not apply any CSS class to the view element.
  * When the value is `null`, the style will be used as the default one. A default style does not apply any CSS class to the view element.
- * @property {String} icon An SVG icon source (as XML string) to use when creating the style's button.
+ * @property {String} icon One of the following to be used when creating the style's button:
+ *  * An SVG icon source (as an XML string),
+ *  * One of {@link module:image/imagestyle/imagestyleengine~ImageStyleEngine.defaultIcons} to use a default icon provided by the plugin.
  * @property {String} title The style's title.
  * @property {String} title The style's title.
  * @property {String} className The CSS class used to represent the style in view.
  * @property {String} className The CSS class used to represent the style in view.
  */
  */

+ 412 - 172
packages/ckeditor5-image/tests/imagestyle/imagestyleengine.js

@@ -7,241 +7,481 @@ import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtest
 import ImageStyleEngine from '../../src/imagestyle/imagestyleengine';
 import ImageStyleEngine from '../../src/imagestyle/imagestyleengine';
 import ImageEngine from '../../src/image/imageengine';
 import ImageEngine from '../../src/image/imageengine';
 import ImageStyleCommand from '../../src/imagestyle/imagestylecommand';
 import ImageStyleCommand from '../../src/imagestyle/imagestylecommand';
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import log from '@ckeditor/ckeditor5-utils/src/log';
 import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
 import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
 
 
+import leftIcon from '@ckeditor/ckeditor5-core/theme/icons/object-left.svg';
+import centerIcon from '@ckeditor/ckeditor5-core/theme/icons/object-center.svg';
+import rightIcon from '@ckeditor/ckeditor5-core/theme/icons/object-right.svg';
+
 describe( 'ImageStyleEngine', () => {
 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;
-			} );
-	} );
+	let editor, plugin, document, viewDocument;
 
 
-	it( 'should be loaded', () => {
-		expect( editor.plugins.get( ImageStyleEngine ) ).to.be.instanceOf( ImageStyleEngine );
+	afterEach( () => {
+		editor.destroy();
 	} );
 	} );
 
 
-	it( 'should load image engine', () => {
-		expect( editor.plugins.get( ImageEngine ) ).to.be.instanceOf( ImageEngine );
-	} );
+	describe( 'plugin', () => {
+		beforeEach( () => {
+			return VirtualTestEditor
+				.create( {
+					plugins: [ ImageStyleEngine ],
+				} )
+				.then( newEditor => {
+					editor = newEditor;
+				} );
+		} );
 
 
-	it( 'should set schema rules for image style', () => {
-		const schema = document.schema;
+		it( 'should be loaded', () => {
+			expect( editor.plugins.get( ImageStyleEngine ) ).to.be.instanceOf( ImageStyleEngine );
+		} );
 
 
-		expect( schema.check( { name: 'image', attributes: [ 'imageStyle', 'src' ], inside: '$root' } ) ).to.be.true;
+		it( 'should load image engine', () => {
+			expect( editor.plugins.get( ImageEngine ) ).to.be.instanceOf( ImageEngine );
+		} );
 	} );
 	} );
 
 
-	it( 'should register separate command for each style', () => {
-		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 );
-	} );
+	describe( 'init', () => {
+		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 convert from view to model', () => {
-		editor.setData( '<figure class="image side-class"><img src="foo.png" /></figure>' );
+		it( 'should define image.styles config', () => {
+			return VirtualTestEditor
+				.create( {
+					plugins: [ ImageStyleEngine ]
+				} )
+				.then( newEditor => {
+					expect( newEditor.config.get( 'image.styles' ) ).to.deep.equal( [ 'imageStyleFull', 'imageStyleSide' ] );
+				} );
+		} );
 
 
-		expect( getModelData( document, { withoutSelection: true } ) ).to.equal( '<image imageStyle="side" src="foo.png"></image>' );
-		expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal(
-			'<figure class="image ck-widget side-class" contenteditable="false">' +
-				'<img src="foo.png"></img>' +
-			'</figure>' );
-	} );
+		it( 'should set schema rules for image style', () => {
+			const schema = document.schema;
 
 
-	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( schema.check( { name: 'image', attributes: [ 'imageStyle', 'src' ], inside: '$root' } ) ).to.be.true;
+		} );
 
 
-		expect( getModelData( document, { withoutSelection: true } ) ).to.equal( '<image src="foo.png"></image>' );
-		expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal(
-			'<figure class="image ck-widget" contenteditable="false"><img src="foo.png"></img></figure>'
-		);
-	} );
+		it( 'should register separate command for each style', () => {
+			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 not convert from view to model when not in image figure', () => {
-		editor.setData( '<figure class="side-class"></figure>' );
+		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( '' );
-		expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( '' );
-	} );
+			expect( getModelData( document, { withoutSelection: true } ) ).to.equal( '<image imageStyle="side" src="foo.png"></image>' );
+			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 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>' );
+		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>' );
-		expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal(
-			'<figure class="image ck-widget" contenteditable="false"><img src="foo.png"></img></figure>'
-		);
-	} );
+			expect( getModelData( document, { withoutSelection: true } ) ).to.equal( '<image src="foo.png"></image>' );
+			expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal(
+				'<figure class="image ck-widget" contenteditable="false"><img src="foo.png"></img></figure>'
+			);
+		} );
 
 
-	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();
+		it( 'should not convert from view to model when not in image figure', () => {
+			editor.setData( '<figure class="side-class"></figure>' );
 
 
-		document.enqueueChanges( () => {
-			batch.setAttribute( image, 'imageStyle', 'side' );
+			expect( getModelData( document, { withoutSelection: true } ) ).to.equal( '' );
+			expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal( '' );
 		} );
 		} );
 
 
-		expect( editor.getData() ).to.equal( '<figure class="image side-class"><img src="foo.png"></figure>' );
-		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 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>' );
+			expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal(
+				'<figure class="image ck-widget" contenteditable="false"><img src="foo.png"></img></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();
+		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' );
+			} );
 
 
-		document.enqueueChanges( () => {
-			batch.setAttribute( image, 'imageStyle', null );
+			expect( editor.getData() ).to.equal( '<figure class="image side-class"><img src="foo.png"></figure>' );
+			expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal(
+				'<figure class="image ck-widget side-class" contenteditable="false"><img src="foo.png"></img></figure>'
+			);
 		} );
 		} );
 
 
-		expect( editor.getData() ).to.equal( '<figure class="image"><img src="foo.png"></figure>' );
-		expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal(
-			'<figure class="image ck-widget" contenteditable="false"><img src="foo.png"></img></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();
 
 
-	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', null );
+			} );
 
 
-		document.enqueueChanges( () => {
-			batch.setAttribute( image, 'imageStyle', 'side' );
+			expect( editor.getData() ).to.equal( '<figure class="image"><img src="foo.png"></figure>' );
+			expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal(
+				'<figure class="image ck-widget" contenteditable="false"><img src="foo.png"></img></figure>'
+			);
 		} );
 		} );
 
 
-		expect( editor.getData() ).to.equal( '<figure class="image side-class"><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>' );
 
 
-		// https://github.com/ckeditor/ckeditor5-image/issues/132
-		expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal(
-			'<figure class="image ck-widget side-class" contenteditable="false"><img src="foo.png"></img></figure>'
-		);
+			// https://github.com/ckeditor/ckeditor5-image/issues/132
+			expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal(
+				'<figure class="image ck-widget side-class" contenteditable="false"><img src="foo.png"></img></figure>'
+			);
 
 
-		document.enqueueChanges( () => {
-			batch.setAttribute( image, 'imageStyle', 'dummy' );
+			document.enqueueChanges( () => {
+				batch.setAttribute( image, 'imageStyle', 'dummy' );
+			} );
+
+			expect( editor.getData() ).to.equal( '<figure class="image dummy-class"><img src="foo.png"></figure>' );
+
+			// https://github.com/ckeditor/ckeditor5-image/issues/132
+			expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal(
+				'<figure class="image ck-widget dummy-class" contenteditable="false"><img src="foo.png"></img></figure>'
+			);
 		} );
 		} );
 
 
-		expect( editor.getData() ).to.equal( '<figure class="image dummy-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' } );
 
 
-		// https://github.com/ckeditor/ckeditor5-image/issues/132
-		expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal(
-			'<figure class="image ck-widget dummy-class" contenteditable="false"><img src="foo.png"></img></figure>'
-		);
-	} );
+			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' } );
 
 
-	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" imageStyle="side"></image>' );
+			const image = document.getRoot().getChild( 0 );
+			const batch = document.batch();
 
 
-		setModelData( document, '<image src="foo.png"></image>' );
-		const image = document.getRoot().getChild( 0 );
-		const batch = document.batch();
+			document.enqueueChanges( () => {
+				batch.setAttribute( image, 'imageStyle', null );
+			} );
 
 
-		document.enqueueChanges( () => {
-			batch.setAttribute( image, 'imageStyle', 'side' );
+			expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal(
+				'<figure class="image ck-widget side-class" contenteditable="false"><img src="foo.png"></img></figure>'
+			);
 		} );
 		} );
 
 
-		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: change attribute', () => {
+			editor.editing.modelToView.on( 'changeAttribute:imageStyle', ( evt, data, consumable ) => {
+				consumable.consume( data.item, 'changeAttribute:imageStyle' );
+			}, { priority: 'high' } );
 
 
-	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="dummy"></image>' );
+			const image = document.getRoot().getChild( 0 );
+			const batch = document.batch();
 
 
-		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', 'side' );
+			} );
 
 
-		document.enqueueChanges( () => {
-			batch.setAttribute( image, 'imageStyle', null );
+			expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal(
+				'<figure class="image ck-widget dummy-class" contenteditable="false"><img src="foo.png"></img></figure>'
+			);
 		} );
 		} );
 
 
-		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 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' );
+			} );
 
 
-	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' } );
+			expect( editor.getData() ).to.equal( '<figure class="image"><img src="foo.png"></figure>' );
+			expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal(
+				'<figure class="image ck-widget" contenteditable="false"><img src="foo.png"></img></figure>'
+			);
+		} );
 
 
-		setModelData( document, '<image src="foo.png" imageStyle="dummy"></image>' );
-		const image = document.getRoot().getChild( 0 );
-		const batch = document.batch();
+		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', 'side' );
+			document.enqueueChanges( () => {
+				batch.setAttribute( image, 'imageStyle', 'foo' );
+			} );
+
+			expect( editor.getData() ).to.equal( '<figure class="image"><img src="foo.png"></figure>' );
+			expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal(
+				'<figure class="image ck-widget" contenteditable="false"><img src="foo.png"></img></figure>'
+			);
 		} );
 		} );
 
 
-		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: 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>' );
+			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 style is not present: adding attribute', () => {
-		setModelData( document, '<image src="foo.png"></image>' );
-		const image = document.getRoot().getChild( 0 );
-		const batch = document.batch();
+	describe( 'imageStyles()', () => {
+		it( 'should fall back to defaults when no image.styles', () => {
+			return VirtualTestEditor
+				.create( {
+					plugins: [ ImageStyleEngine ]
+				} )
+				.then( newEditor => {
+					expect( newEditor.config.get( 'image.styles' ) ).to.deep.equal( [ 'imageStyleFull', 'imageStyleSide' ] );
+				} );
+		} );
 
 
-		document.enqueueChanges( () => {
-			batch.setAttribute( image, 'imageStyle', 'foo' );
+		it( 'should not alter the image.styles config', () => {
+			return VirtualTestEditor
+				.create( {
+					plugins: [ ImageStyleEngine ],
+					image: {
+						styles: [
+							'imageStyleSide'
+						]
+					}
+				} )
+				.then( newEditor => {
+					expect( newEditor.config.get( 'image.styles' ) ).to.deep.equal( [ 'imageStyleSide' ] );
+				} );
 		} );
 		} );
 
 
-		expect( editor.getData() ).to.equal( '<figure class="image"><img src="foo.png"></figure>' );
-		expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal(
-			'<figure class="image ck-widget" contenteditable="false"><img src="foo.png"></img></figure>'
-		);
-	} );
+		it( 'should not alter object definitions in the image.styles config', () => {
+			return VirtualTestEditor
+				.create( {
+					plugins: [ ImageStyleEngine ],
+					image: {
+						styles: [
+							{ name: 'imageStyleSide' }
+						]
+					}
+				} )
+				.then( newEditor => {
+					expect( newEditor.config.get( 'image.styles' ) ).to.deep.equal( [ { name: 'imageStyleSide' } ] );
+				} );
+		} );
+
+		it( 'should cache the styles', () => {
+			return VirtualTestEditor
+				.create( {
+					plugins: [ ImageStyleEngine ]
+				} )
+				.then( newEditor => {
+					editor = newEditor;
+					plugin = editor.plugins.get( ImageStyleEngine );
+
+					expect( plugin.imageStyles ).to.equal( plugin.imageStyles );
+				} );
+		} );
+
+		describe( 'object format', () => {
+			beforeEach( () => {
+				class TranslationMock extends Plugin {
+					init() {
+						sinon.stub( this.editor, 't' ).returns( 'Default translation' );
+					}
+				}
+
+				return VirtualTestEditor
+					.create( {
+						plugins: [ TranslationMock, ImageStyleEngine ],
+						image: {
+							styles: [
+								// Custom user styles.
+								{ name: 'foo', title: 'foo', icon: 'custom', value: null, className: 'foo-class' },
+								{ name: 'bar', title: 'bar', icon: 'right', value: 'bar', className: 'bar-class' },
+								{ name: 'baz', title: 'Side image', icon: 'custom', value: 'baz', className: 'baz-class' },
+
+								// Customized default styles.
+								{ name: 'imageStyleFull', icon: 'left', title: 'Custom title' }
+							]
+						}
+					} )
+					.then( newEditor => {
+						editor = newEditor;
+						plugin = editor.plugins.get( ImageStyleEngine );
+					} );
+			} );
+
+			it( 'should pass through if #name not found in default styles', () => {
+				expect( plugin.imageStyles[ 0 ] ).to.deep.equal( {
+					name: 'foo',
+					title: 'foo',
+					icon: 'custom',
+					value: null,
+					className: 'foo-class'
+				} );
+			} );
 
 
-	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();
+			it( 'should use one of default icons if #icon matches', () => {
+				expect( plugin.imageStyles[ 1 ].icon ).to.equal( ImageStyleEngine.defaultIcons.right );
+			} );
+
+			it( 'should use one of default translations if #title matches', () => {
+				expect( plugin.imageStyles[ 2 ].title ).to.deep.equal( 'Default translation' );
+			} );
 
 
-		document.enqueueChanges( () => {
-			batch.setAttribute( image, 'imageStyle', 'foo' );
+			it( 'should extend one of default styles if #name matches', () => {
+				expect( plugin.imageStyles[ 3 ] ).to.deep.equal( {
+					name: 'imageStyleFull',
+					title: 'Custom title',
+					icon: ImageStyleEngine.defaultIcons.left,
+					value: null
+				} );
+			} );
 		} );
 		} );
 
 
-		expect( editor.getData() ).to.equal( '<figure class="image"><img src="foo.png"></figure>' );
-		expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal(
-			'<figure class="image ck-widget" contenteditable="false"><img src="foo.png"></img></figure>'
-		);
-	} );
+		describe( 'string format', () => {
+			it( 'should use one of default styles if #name matches', () => {
+				return VirtualTestEditor
+					.create( {
+						plugins: [ ImageStyleEngine ],
+						image: {
+							styles: [ 'imageStyleFull' ]
+						}
+					} )
+					.then( newEditor => {
+						editor = newEditor;
+						plugin = editor.plugins.get( ImageStyleEngine );
+						expect( plugin.imageStyles[ 0 ] ).to.deep.equal( ImageStyleEngine.defaultStyles.imageStyleFull );
+					} );
+			} );
 
 
-	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();
+			it( 'should warn if a #name not found in default styles', () => {
+				sinon.stub( log, 'warn' );
+
+				return VirtualTestEditor
+					.create( {
+						plugins: [ ImageStyleEngine ],
+						image: {
+							styles: [ 'foo' ]
+						}
+					} )
+					.then( newEditor => {
+						editor = newEditor;
+						plugin = editor.plugins.get( ImageStyleEngine );
+
+						expect( plugin.imageStyles[ 0 ] ).to.deep.equal( {
+							name: 'foo'
+						} );
+
+						sinon.assert.calledOnce( log.warn );
+						sinon.assert.calledWithExactly( log.warn,
+							sinon.match( /^image-style-not-found/ ),
+							{ name: 'foo' }
+						);
+					} );
+			} );
+		} );
+	} );
 
 
-		document.enqueueChanges( () => {
-			batch.setAttribute( image, 'imageStyle', null );
+	describe( 'defaultStyles', () => {
+		it( 'should be defined', () => {
+			expect( ImageStyleEngine.defaultStyles ).to.deep.equal( {
+				imageStyleFull: {
+					name: 'imageStyleFull',
+					title: 'Full size image',
+					icon: centerIcon,
+					value: null
+				},
+				imageStyleSide: {
+					name: 'imageStyleSide',
+					title: 'Side image',
+					icon: rightIcon,
+					value: 'side',
+					className: 'image-style-side'
+				},
+				imageStyleLeft: {
+					name: 'imageStyleLeft',
+					title: 'Left aligned image',
+					icon: leftIcon,
+					value: 'left',
+					className: 'image-style-align-left'
+				},
+				imageStyleCenter: {
+					name: 'imageStyleCenter',
+					title: 'Centered image',
+					icon: centerIcon,
+					value: 'side',
+					className: 'image-style-align-center'
+				},
+				imageStyleRight: {
+					name: 'imageStyleRight',
+					title: 'Right aligned image',
+					icon: rightIcon,
+					value: 'right',
+					className: 'image-style-align-right'
+				}
+			} );
 		} );
 		} );
+	} );
 
 
-		expect( editor.getData() ).to.equal( '<figure class="image"><img src="foo.png"></figure>' );
-		expect( getViewData( viewDocument, { withoutSelection: true } ) ).to.equal(
-			'<figure class="image ck-widget" contenteditable="false"><img src="foo.png"></img></figure>'
-		);
+	describe( 'defaultIcons', () => {
+		it( 'should be defined', () => {
+			expect( ImageStyleEngine.defaultIcons ).to.deep.equal( {
+				left: leftIcon,
+				right: rightIcon,
+				center: centerIcon,
+			} );
+		} );
 	} );
 	} );
 } );
 } );