Procházet zdrojové kódy

Merge pull request #27 from ckeditor/t/24

Image alternate text.
Piotrek Koszuliński před 9 roky
rodič
revize
b609dd2c28
22 změnil soubory, kde provedl 1145 přidání a 165 odebrání
  1. 3 2
      packages/ckeditor5-image/package.json
  2. 2 1
      packages/ckeditor5-image/src/image.js
  3. 152 0
      packages/ckeditor5-image/src/imagealternatetext/imagealternatetext.js
  4. 86 0
      packages/ckeditor5-image/src/imagealternatetext/imagealternatetextcommand.js
  5. 26 0
      packages/ckeditor5-image/src/imagealternatetext/imagealternatetextengine.js
  6. 128 0
      packages/ckeditor5-image/src/imagealternatetext/ui/alternatetextformview.js
  7. 1 1
      packages/ckeditor5-image/src/imageengine.js
  8. 39 74
      packages/ckeditor5-image/src/imagetoolbar.js
  9. 119 0
      packages/ckeditor5-image/src/ui/imageballoonpanelview.js
  10. 7 2
      packages/ckeditor5-image/tests/image.js
  11. 180 0
      packages/ckeditor5-image/tests/imagealternatetext/imagealternatetext.js
  12. 85 0
      packages/ckeditor5-image/tests/imagealternatetext/imagealternatetextcommand.js
  13. 24 0
      packages/ckeditor5-image/tests/imagealternatetext/imagelaternatetextengine.js
  14. 52 0
      packages/ckeditor5-image/tests/imagealternatetext/ui/alternatetextformview.js
  15. 20 83
      packages/ckeditor5-image/tests/imagetoolbar.js
  16. 8 0
      packages/ckeditor5-image/tests/manual/alternatetext.html
  17. 30 0
      packages/ckeditor5-image/tests/manual/alternatetext.js
  18. 10 0
      packages/ckeditor5-image/tests/manual/alternatetext.md
  19. 1 1
      packages/ckeditor5-image/tests/manual/imagestyle.md
  20. 140 0
      packages/ckeditor5-image/tests/ui/imageballoonpanelview.js
  21. 32 0
      packages/ckeditor5-image/theme/imagealternatetext/theme.scss
  22. 0 1
      packages/ckeditor5-image/theme/theme.scss

+ 3 - 2
packages/ckeditor5-image/package.json

@@ -6,7 +6,9 @@
   "dependencies": {
     "@ckeditor/ckeditor5-core": "*",
     "@ckeditor/ckeditor5-engine": "*",
-    "@ckeditor/ckeditor5-ui": "*"
+    "@ckeditor/ckeditor5-ui": "*",
+    "@ckeditor/ckeditor5-utils": "*",
+    "@ckeditor/ckeditor5-theme-lark": "*"
   },
   "devDependencies": {
     "@ckeditor/ckeditor5-clipboard": "*",
@@ -17,7 +19,6 @@
     "@ckeditor/ckeditor5-paragraph": "*",
     "@ckeditor/ckeditor5-typing": "*",
     "@ckeditor/ckeditor5-undo": "*",
-    "@ckeditor/ckeditor5-utils": "*",
     "gulp": "^3.9.1",
     "guppy-pre-commit": "^0.4.0"
   },

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

@@ -10,6 +10,7 @@
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import ImageEngine from './imageengine';
 import Widget from './widget/widget';
+import ImageAlternateText from './imagealternatetext/imagealternatetext';
 
 import '../theme/theme.scss';
 
@@ -25,6 +26,6 @@ export default class Image extends Plugin {
 	 * @inheritDoc
 	 */
 	static get requires() {
-		return [ ImageEngine, Widget ];
+		return [ ImageEngine, Widget, ImageAlternateText ];
 	}
 }

+ 152 - 0
packages/ckeditor5-image/src/imagealternatetext/imagealternatetext.js

@@ -0,0 +1,152 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module image/imagealternatetext/imagealternatetext
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+import ImageAlternateTextEngine from './imagealternatetextengine';
+import escPressHandler from '@ckeditor/ckeditor5-ui/src/bindings/escpresshandler';
+import ImageToolbar from '../imagetoolbar';
+import AlternateTextFormView from './ui/alternatetextformview';
+import ImageBalloonPanel from '../ui/imageballoonpanelview';
+
+import alternateTextIcon from '@ckeditor/ckeditor5-core/theme/icons/input.svg';
+import '../../theme/imagealternatetext/theme.scss';
+
+/**
+ * The image alternate text plugin.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class ImageAlternateText extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ ImageAlternateTextEngine ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		this._createButton();
+
+		return this._createBalloonPanel().then( panel => {
+			/**
+			 * Balloon panel containing alternate text change form.
+			 *
+			 * @member {module:image/ui/imageballoonpanel~ImageBalloonPanelView} #baloonPanel
+			 */
+			this.balloonPanel = panel;
+
+			/**
+			 * Form containing textarea and buttons, used to change `alt` text value.
+			 *
+			 * @member {module:image/imagealternatetext/ui/imagealternatetextformview~AlternateTextFormView} #form
+			 */
+			this.form = panel.content.get( 0 );
+		} );
+	}
+
+	/**
+	 * Creates button showing alternate text change balloon panel and registers it in
+	 * editor's {@link module:ui/componentfactory~ComponentFactory ComponentFactory}.
+	 *
+	 * @private
+	 */
+	_createButton() {
+		const editor = this.editor;
+		const command = editor.commands.get( 'imageAlternateText' );
+		const t = editor.t;
+
+		editor.ui.componentFactory.add( 'imageAlternateText', ( locale ) => {
+			const view = new ButtonView( locale );
+
+			view.set( {
+				label: t( 'Change alternate text' ),
+				icon: alternateTextIcon,
+				tooltip: true
+			} );
+
+			view.bind( 'isEnabled' ).to( command, 'isEnabled' );
+
+			this.listenTo( view, 'execute', () => this._showBalloonPanel() );
+
+			return view;
+		} );
+	}
+
+	/**
+	 * Creates balloon panel.
+	 *
+	 * @private
+	 * @return {Promise.<module:image/ui/imageballoonpanel~ImageBalloonPanelView>}
+	 */
+	_createBalloonPanel() {
+		const editor = this.editor;
+
+		const panel = new ImageBalloonPanel( editor );
+		const form = new AlternateTextFormView( editor.locale );
+
+		this.listenTo( form, 'submit', () => {
+			editor.execute( 'imageAlternateText', { newValue: form.lebeledInput.inputView.element.value } );
+			this._hideBalloonPanel();
+		} );
+
+		this.listenTo( form, 'cancel', () => this._hideBalloonPanel() );
+
+		// Close on `ESC` press.
+		escPressHandler( {
+			emitter: panel,
+			activator: () => panel.isVisible,
+			callback: () => this._hideBalloonPanel()
+		} );
+
+		return Promise.all( [
+			panel.content.add( form ),
+			editor.ui.view.body.add( panel )
+		] ).then( () => panel ) ;
+	}
+
+	/**
+	 * Shows the balloon panel.
+	 *
+	 * @private
+	 */
+	_showBalloonPanel() {
+		const editor = this.editor;
+		const command = editor.commands.get( 'imageAlternateText' );
+		const imageToolbar = editor.plugins.get( ImageToolbar );
+
+		if ( imageToolbar ) {
+			imageToolbar.hide();
+		}
+
+		this.form.lebeledInput.value = command.value || '';
+		this.balloonPanel.attach();
+		this.form.lebeledInput.select();
+	}
+
+	/**
+	 * Hides the balloon panel.
+	 *
+	 * @private
+	 */
+	_hideBalloonPanel() {
+		const editor = this.editor;
+		this.balloonPanel.detach();
+		editor.editing.view.focus();
+
+		const imageToolbar = editor.plugins.get( ImageToolbar );
+
+		if ( imageToolbar ) {
+			imageToolbar.show();
+		}
+	}
+}

+ 86 - 0
packages/ckeditor5-image/src/imagealternatetext/imagealternatetextcommand.js

@@ -0,0 +1,86 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module image/imagelaternatetext/imagealternatetextcommand
+ */
+
+import Command from '@ckeditor/ckeditor5-core/src/command/command';
+import { isImage } from '../utils';
+
+/**
+ * The image alternate text command. It is used to change `alt` attribute on `image` elements.
+ *
+ * @extends module:core/command/command~Command
+ */
+export default class ImageAlternateTextCommand extends Command {
+	/**
+	 * @inheritDoc
+	 */
+	constructor( editor ) {
+		super( editor );
+		/**
+		 * The current command value - `false` if there is no `alt` attribute, otherwise contains string with `alt`
+		 * attribute value.
+		 *
+		 * @readonly
+		 * @observable
+		 * @member {String|Boolean} #value
+		 */
+		this.set( 'value', false );
+
+		// 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 ( isImage( element ) && element.hasAttribute( 'alt' ) ) {
+			this.value = element.getAttribute( 'alt' );
+		} else {
+			this.value = false;
+		}
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	_checkEnabled() {
+		const element = this.editor.document.selection.getSelectedElement();
+
+		return isImage( element );
+	}
+
+	/**
+	 * Executes command.
+	 *
+	 * @protected
+	 * @param {Object} options
+	 * @param {String} options.newValue New value of `alt` attribute to set.
+	 * @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 ) {
+		const editor = this.editor;
+		const doc = editor.document;
+		const imageElement = doc.selection.getSelectedElement();
+
+		doc.enqueueChanges( () => {
+			const batch = options.batch || doc.batch();
+
+			batch.setAttribute( imageElement, 'alt', options.newValue );
+		} );
+	}
+}

+ 26 - 0
packages/ckeditor5-image/src/imagealternatetext/imagealternatetextengine.js

@@ -0,0 +1,26 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module image/imagealternatetext/imagealternatetextengine
+ */
+
+import ImageAlternateTextCommand from './imagealternatetextcommand';
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+
+/**
+ * The ImageAlternateTextEngine plugin.
+ * Registers `imageAlternateText` command.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class ImageAlternateTextEngine extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		this.editor.commands.set( 'imageAlternateText', new ImageAlternateTextCommand( this.editor ) );
+	}
+}

+ 128 - 0
packages/ckeditor5-image/src/imagealternatetext/ui/alternatetextformview.js

@@ -0,0 +1,128 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module image/imagealternatetext/ui/imagealternatetextformview
+ */
+
+import View from '@ckeditor/ckeditor5-ui/src/view';
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+import Template from '@ckeditor/ckeditor5-ui/src/template';
+import LabeledInputView from '@ckeditor/ckeditor5-ui/src/labeledinput/labeledinputview';
+import InputTextView from '@ckeditor/ckeditor5-ui/src/inputtext/inputtextview';
+import submitHandler from '@ckeditor/ckeditor5-ui/src/bindings/submithandler';
+
+/**
+ * AlternateTextFormView class.
+ *
+ * @extends module:ui/view~View
+ */
+export default class AlternateTextFormView extends View {
+	/**
+	 * @inheritDoc
+	 */
+	constructor( locale ) {
+		super( locale );
+
+		const t = this.locale.t;
+
+		/**
+		 * Text area with label.
+		 *
+		 * @member {module:ui/labeledinput/labeledinputview~LabeledInputView} #labeledTextarea
+		 */
+		this.lebeledInput = this._createLabeledInputView();
+
+		/**
+		 * Button used to submit the form.
+		 *
+		 * @member {module:ui/button/buttonview~ButtonView} #saveButtonView
+		 */
+		this.saveButtonView = this._createButton( t( 'Save' ) );
+		this.saveButtonView.type = 'submit';
+
+		/**
+		 * Button used to cancel the form.
+		 *
+		 * @member {module:ui/button/buttonview~ButtonView} #cancelButtonView
+		 */
+		this.cancelButtonView = this._createButton( t( 'Cancel' ), 'cancel' );
+
+		Template.extend( this.saveButtonView.template, {
+			attributes: {
+				class: [
+					'ck-button-action'
+				]
+			}
+		} );
+
+		this.template = new Template( {
+			tag: 'form',
+
+			attributes: {
+				class: [
+					'ck-alternate-text-form',
+				]
+			},
+
+			children: [
+				this.lebeledInput,
+				{
+					tag: 'div',
+
+					attributes: {
+						class: [
+							'ck-alternate-text-form__actions'
+						]
+					},
+
+					children: [
+						this.saveButtonView,
+						this.cancelButtonView
+					]
+				}
+			]
+		} );
+
+		submitHandler( {
+			view: this
+		} );
+	}
+
+	/**
+	 * Creates button view.
+	 *
+	 * @private
+	 * @param {String} label Button label
+	 * @param {String} [eventName] Event name which ButtonView#execute event will be delegated to.
+	 * @returns {module:ui/button/buttonview~ButtonView} Button view instance.
+	 */
+	_createButton( label, eventName ) {
+		const button = new ButtonView( this.locale );
+
+		button.label = label;
+		button.withText = true;
+
+		if ( eventName ) {
+			button.delegate( 'execute' ).to( this, eventName );
+		}
+
+		return button;
+	}
+
+	/**
+	 * Creates input with label.
+	 *
+	 * @private
+	 * @return {module:ui/labeledinput/labeledinputview~LabeledInputView}
+	 */
+	_createLabeledInputView() {
+		const t = this.locale.t;
+		const labeledInput = new LabeledInputView( this.locale, InputTextView );
+		labeledInput.label = t( 'Alternate image text' );
+
+		return labeledInput;
+	}
+}

+ 1 - 1
packages/ckeditor5-image/src/imageengine.js

@@ -20,7 +20,7 @@ import ViewEmptyElement from '@ckeditor/ckeditor5-engine/src/view/emptyelement';
  * Registers `image` as a block element in document's schema and allows it to have two attributes: `src` and `alt`.
  * Registers converters for editing and data pipelines.
  *
- * @extends module:core/plugin~Plugin.
+ * @extends module:core/plugin~Plugin
  */
 export default class ImageEngine extends Plugin {
 	/**

+ 39 - 74
packages/ckeditor5-image/src/imagetoolbar.js

@@ -7,38 +7,11 @@
  * @module image/imagetoolbar
  */
 
+import Template from '@ckeditor/ckeditor5-ui/src/template';
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import ToolbarView from '@ckeditor/ckeditor5-ui/src/toolbar/toolbarview';
-import BalloonPanelView from '@ckeditor/ckeditor5-ui/src/balloonpanel/balloonpanelview';
-import Template from '@ckeditor/ckeditor5-ui/src/template';
 import { isImageWidget } from './utils';
-import throttle from '@ckeditor/ckeditor5-utils/src/lib/lodash/throttle';
-import global from '@ckeditor/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'
-	} )
-};
+import ImageBalloonPanel from './ui/imageballoonpanelview';
 
 /**
  * Image toolbar class. Creates image toolbar placed inside balloon panel that is showed when image widget is selected.
@@ -71,12 +44,11 @@ export default class ImageToolbar extends Plugin {
 			return;
 		}
 
-		// Create a plain toolbar instance.
+		const panel = this._panel = new ImageBalloonPanel( editor );
+		const promises = [];
 		const toolbar = new ToolbarView();
 
-		// Create a BalloonPanelView instance.
-		const panel = new BalloonPanelView( editor.locale );
-
+		// Add CSS class to the panel.
 		Template.extend( panel.template, {
 			attributes: {
 				class: [
@@ -85,54 +57,47 @@ export default class ImageToolbar extends Plugin {
 			}
 		} );
 
-		// 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 );
+		// Add toolbar to balloon panel.
+		promises.push( panel.content.add( toolbar ) );
 
-			// 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();
-				}
-			} );
+		// Add buttons to the toolbar.
+		for ( let name of toolbarConfig ) {
+			promises.push( toolbar.items.add( editor.ui.componentFactory.create( name ) ) );
+		}
 
-			const attachToolbarCallback = throttle( attachToolbar, 100 );
+		// Add balloon panel to editor's UI.
+		promises.push( editor.ui.view.body.add( panel ) );
 
-			// Check if the toolbar should be displayed each time view is rendered.
-			editor.listenTo( editingView, 'render', () => {
-				const selectedElement = editingView.selection.getSelectedElement();
+		// Show balloon panel each time image widget is selected.
+		this.listenTo( this.editor.editing.view, 'render', () => {
+			this.show();
+		}, { priority: 'low' } );
 
-				if ( selectedElement && isImageWidget( selectedElement ) ) {
-					attachToolbar();
+		// There is no render method after focus is back in editor, we need to check if balloon panel should be visible.
+		this.listenTo( editor.ui.focusTracker, 'change:isFocused', ( evt, name, is, was ) => {
+			if ( !was && is ) {
+				this.show();
+			}
+		} );
 
-					editor.ui.view.listenTo( global.window, 'scroll', attachToolbarCallback );
-					editor.ui.view.listenTo( global.window, 'resize', attachToolbarCallback );
-				} else {
-					panel.hide();
+		return Promise.all( promises );
+	}
 
-					editor.ui.view.stopListening( global.window, 'scroll', attachToolbarCallback );
-					editor.ui.view.stopListening( global.window, 'resize', attachToolbarCallback );
-				}
-			}, { priority: 'low' } );
+	/**
+	 * Shows the toolbar.
+	 */
+	show() {
+		const selectedElement = this.editor.editing.view.selection.getSelectedElement();
 
-			function attachToolbar() {
-				panel.attachTo( {
-					target: editingView.domConverter.viewRangeToDom( editingView.selection.getFirstRange() ),
-					positions: [ positions.north, positions.south ]
-				} );
-			}
+		if ( selectedElement && isImageWidget( selectedElement ) ) {
+			this._panel.attach();
+		}
+	}
 
-			return Promise.all( promises );
-		} );
+	/**
+	 * Hides the toolbar.
+	 */
+	hide() {
+		this._panel.detach();
 	}
 }

+ 119 - 0
packages/ckeditor5-image/src/ui/imageballoonpanelview.js

@@ -0,0 +1,119 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module image/ui/imageballoonpanel
+ */
+
+import throttle from '@ckeditor/ckeditor5-utils/src/lib/lodash/throttle';
+import global from '@ckeditor/ckeditor5-utils/src/dom/global';
+import BalloonPanelView from '@ckeditor/ckeditor5-ui/src/balloonpanel/balloonpanelview';
+import { isImageWidget } from '../utils';
+
+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 balloon panel class. It extends {module:ui/balloonpanel/balloonpanelview~BalloonPanelView} by adding helpers
+ * to use with image widgets. It sets proper positioning on `scroll` and `resize` events and hides the panel when
+ * image is no longer selected or focus is lost.
+ *
+ * @extends module:ui/balloonpanel/balloonpanelview~BalloonPanelView
+ */
+export default class ImageBalloonPanelView extends BalloonPanelView {
+	/**
+	 * @inheritDoc
+	 */
+	constructor( editor ) {
+		super( editor.locale );
+
+		this.editor = editor;
+		const editingView = editor.editing.view;
+
+		// Let the focusTracker know about new focusable UI element.
+		editor.ui.focusTracker.add( this.element );
+
+		// Hide the balloon if editor had focus and now focus is lost.
+		this.listenTo( editor.ui.focusTracker, 'change:isFocused', ( evt, name, is, was ) => {
+			if ( was && !is ) {
+				this.detach();
+			}
+		} );
+
+		// Hide the balloon if no image is currently selected.
+		editor.listenTo( editingView, 'render', () => {
+			const selectedElement = editingView.selection.getSelectedElement();
+
+			if ( !selectedElement || !isImageWidget( selectedElement ) ) {
+				this.detach();
+			}
+		}, { priority: 'low' } );
+
+		/**
+		 * Wraps {@link #_attach} method with throttle function that will fire it not more than every 100ms.
+		 * It is used as `scroll` and `resize` callback.
+		 *
+		 * @private
+		 * @member {Function} #_throttledAttach
+		 */
+		this._throttledAttach = throttle( () => {
+			this._attach();
+		}, 100 );
+	}
+
+	/**
+	 * Attaches the panel and enables `scroll` and `resize` listeners.
+	 */
+	attach() {
+		this._attach();
+		this.editor.ui.view.listenTo( global.window, 'scroll', this._throttledAttach );
+		this.editor.ui.view.listenTo( global.window, 'resize', this._throttledAttach );
+	}
+
+	/**
+	 * Detaches the panel and disables `scroll` and `resize` listeners.
+	 */
+	detach() {
+		this.hide();
+		this.editor.ui.view.stopListening( global.window, 'scroll', this._throttledAttach );
+		this.editor.ui.view.stopListening( global.window, 'resize', this._throttledAttach );
+	}
+
+	/**
+	 * Attaches the panel to the first selection range.
+	 *
+	 * @private
+	 */
+	_attach() {
+		const editingView = this.editor.editing.view;
+
+		this.attachTo( {
+			target: editingView.domConverter.viewRangeToDom( editingView.selection.getFirstRange() ),
+			positions: [ positions.north, positions.south ]
+		} );
+	}
+}

+ 7 - 2
packages/ckeditor5-image/tests/image.js

@@ -9,6 +9,7 @@ import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictest
 import Image from '../src/image';
 import ImageEngine from '../src/imageengine';
 import Widget from '../src/widget/widget';
+import ImageAlternateText from '../src/imagealternatetext/imagealternatetext';
 
 describe( 'Image', () => {
 	let editor;
@@ -29,11 +30,15 @@ describe( 'Image', () => {
 		expect( editor.plugins.get( Image ) ).to.instanceOf( Image );
 	} );
 
-	it( 'should load ImageEngine feature', () => {
+	it( 'should load ImageEngine plugin', () => {
 		expect( editor.plugins.get( ImageEngine ) ).to.instanceOf( ImageEngine );
 	} );
 
-	it( 'should load Widget feature', () => {
+	it( 'should load Widget plugin', () => {
 		expect( editor.plugins.get( Widget ) ).to.instanceOf( Widget );
 	} );
+
+	it( 'should load ImageAlternateText plugin', () => {
+		expect( editor.plugins.get( ImageAlternateText ) ).to.instanceOf( ImageAlternateText );
+	} );
 } );

+ 180 - 0
packages/ckeditor5-image/tests/imagealternatetext/imagealternatetext.js

@@ -0,0 +1,180 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+import Image from '../../src/image';
+import ImageToolbar from '../../src/imagetoolbar';
+import ImageAlternateText from '../../src/imagealternatetext/imagealternatetext';
+import ImageAlternateTextEngine from '../../src/imagealternatetext/imagealternatetextengine';
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+import global from '@ckeditor/ckeditor5-utils/src/dom/global';
+import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
+
+describe( 'ImageAlternateText', () => {
+	let editor, plugin, command, balloonPanel, form;
+
+	beforeEach( () => {
+		const editorElement = global.document.createElement( 'div' );
+		global.document.body.appendChild( editorElement );
+
+		return ClassicTestEditor.create( editorElement, {
+			plugins: [ ImageAlternateText, Image ]
+		} )
+		.then( newEditor => {
+			editor = newEditor;
+			newEditor.editing.view.attachDomRoot( editorElement );
+			plugin = editor.plugins.get( ImageAlternateText );
+			command = editor.commands.get( 'imageAlternateText' );
+			balloonPanel = plugin.balloonPanel;
+			form = plugin.form;
+		} );
+	} );
+
+	afterEach( () => {
+		return editor.destroy();
+	} );
+
+	it( 'should be loaded', () => {
+		expect( plugin ).to.be.instanceOf( ImageAlternateText );
+	} );
+
+	it( 'should load ImageAlternateTextEngine plugin', () => {
+		expect( editor.plugins.get( ImageAlternateTextEngine ) ).to.be.instanceOf( ImageAlternateTextEngine );
+	} );
+
+	describe( 'toolbar button', () => {
+		let button;
+
+		beforeEach( () => {
+			button = editor.ui.componentFactory.create( 'imageAlternateText' );
+		} );
+
+		it( 'should be registered in component factory', () => {
+			expect( button ).to.be.instanceOf( ButtonView );
+		} );
+
+		it( 'should have isEnabled property bind to command\'s isEnabled property', () => {
+			command.isEnabled = true;
+			expect( button.isEnabled ).to.be.true;
+
+			command.isEnabled = false;
+			expect( button.isEnabled ).to.be.false;
+		} );
+
+		it( 'should show balloon panel on execute', () => {
+			const spy = sinon.spy( balloonPanel, 'attach' );
+			setData( editor.document, '[<image src="" alt="foo bar"></image>]' );
+			button.fire( 'execute' );
+
+			sinon.assert.calledOnce( spy );
+		} );
+
+		it( 'should hide ImageToolbar on execute', () => {
+			const editorElement = global.document.createElement( 'div' );
+			global.document.body.appendChild( editorElement );
+
+			return ClassicTestEditor.create( editorElement, {
+				plugins: [ ImageAlternateText, Image, ImageToolbar ],
+				image: {
+					toolbar: [ 'imageAlternateText' ]
+				}
+			} )
+			.then( newEditor => {
+				newEditor.editing.view.attachDomRoot( editorElement );
+				const button = newEditor.ui.componentFactory.create( 'imageAlternateText' );
+				const toolbarPlugin = newEditor.plugins.get( ImageToolbar );
+
+				const spy = sinon.spy( toolbarPlugin, 'hide' );
+				setData( newEditor.document, '[<image src="" alt="foo bar"></image>]' );
+				button.fire( 'execute' );
+
+				sinon.assert.calledOnce( spy );
+
+				newEditor.destroy();
+			} );
+		} );
+
+		it( 'should set alt attribute value to textarea and select it', () => {
+			const spy = sinon.spy( form.lebeledInput, 'select' );
+			setData( editor.document, '[<image src="" alt="foo bar"></image>]' );
+			button.fire( 'execute' );
+
+			sinon.assert.calledOnce( spy );
+			expect( plugin.form.lebeledInput.value ).equals( 'foo bar' );
+		} );
+
+		it( 'should set empty text to textarea and select it when there is no alt attribute', () => {
+			const spy = sinon.spy( form.lebeledInput, 'select' );
+			setData( editor.document, '[<image src=""></image>]' );
+			button.fire( 'execute' );
+
+			sinon.assert.calledOnce( spy );
+			expect( plugin.form.lebeledInput.value ).equals( '' );
+		} );
+	} );
+
+	describe( 'balloon panel form', () => {
+		it( 'should execute command on submit', () => {
+			const spy = sinon.spy( editor, 'execute' );
+			form.fire( 'submit' );
+
+			sinon.assert.calledOnce( spy );
+			sinon.assert.calledWithExactly( spy, 'imageAlternateText', { newValue: form.lebeledInput.inputView.element.value } );
+		} );
+
+		it( 'should detach panel on cancel', () => {
+			const spy = sinon.spy( balloonPanel, 'detach' );
+			form.fire( 'cancel' );
+
+			sinon.assert.called( spy );
+		} );
+
+		it( 'should show ImageToolbar on cancel if present', () => {
+			const editorElement = global.document.createElement( 'div' );
+			global.document.body.appendChild( editorElement );
+
+			return ClassicTestEditor.create( editorElement, {
+				plugins: [ ImageAlternateText, Image, ImageToolbar ],
+				image: {
+					toolbar: [ 'imageAlternateText' ]
+				}
+			} )
+			.then( newEditor => {
+				newEditor.editing.view.attachDomRoot( editorElement );
+				const plugin = newEditor.plugins.get( ImageAlternateText );
+				const toolbarPlugin = newEditor.plugins.get( ImageToolbar );
+				const form = plugin.form;
+
+				const spy = sinon.spy( toolbarPlugin, 'show' );
+				form.fire( 'cancel' );
+
+				sinon.assert.called( spy );
+			} );
+		} );
+
+		describe( 'close listeners', () => {
+			let hidePanelSpy;
+
+			beforeEach( () => {
+				hidePanelSpy = sinon.spy( balloonPanel, 'detach' );
+			} );
+
+			describe( 'keyboard', () => {
+				it( 'should close after `ESC` press', () => {
+					balloonPanel.isVisible = true;
+					const keyCode = keyCodes.esc;
+					const event = global.document.createEvent( 'Events' );
+					event.initEvent( 'keydown', true, true );
+					event.which = keyCode;
+					event.keyCode = keyCode;
+					global.document.dispatchEvent( event );
+
+					sinon.assert.called( hidePanelSpy );
+				} );
+			} );
+		} );
+	} );
+} );

+ 85 - 0
packages/ckeditor5-image/tests/imagealternatetext/imagealternatetextcommand.js

@@ -0,0 +1,85 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import ImageAlternateTextCommand from '../../src/imagealternatetext/imagealternatetextcommand';
+import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+describe( 'ImageAlternateTextCommand', () => {
+	let document, command;
+
+	beforeEach( () => {
+		return ModelTestEditor.create()
+			.then( newEditor => {
+				document = newEditor.document;
+				command = new ImageAlternateTextCommand( newEditor );
+
+				document.schema.registerItem( 'p', '$block' );
+
+				document.schema.registerItem( 'image' );
+				document.schema.requireAttributes( 'image', [ 'src' ] );
+				document.schema.allow( { name: 'image', attributes: [ 'alt', 'src' ], inside: '$root' } );
+				document.schema.objects.add( 'image' );
+			} );
+	} );
+
+	it( 'should have false value if no image is selected', () => {
+		setData( document, '[]<p></p>' );
+
+		expect( command.value ).to.be.false;
+	} );
+
+	it( 'should have false value if image without alt is selected', () => {
+		setData( document, '[<image src="image.png"></image>]' );
+
+		expect( command.value ).to.be.false;
+	} );
+
+	it( 'should be disabled if not on image element', () => {
+		setData( document, '[]<p></p>' );
+
+		expect( command.isEnabled ).to.be.false;
+	} );
+
+	it( 'should be enabled on image element without alt attribute', () => {
+		setData( document, '[<image src="image.png"></image>]' );
+
+		expect( command.isEnabled ).to.be.true;
+	} );
+
+	it( 'should have proper value if on image element with alt attribute', () => {
+		setData( document, '[<image src="image.png" alt="foo bar baz"></image>]' );
+
+		expect( command.value ).to.equal( 'foo bar baz' );
+	} );
+
+	it( 'should set proper alt if executed on image without alt attribute', () => {
+		setData( document, '[<image src="image.png"></image>]' );
+
+		command._doExecute( { newValue: 'fiz buz' } );
+
+		expect( getData( document ) ).to.equal( '[<image alt="fiz buz" src="image.png"></image>]' );
+	} );
+
+	it( 'should change alt if executed on image with alt attribute', () => {
+		setData( document, '[<image alt="foo bar" src="image.png"></image>]' );
+
+		command._doExecute( { newValue: 'fiz buz' } );
+
+		expect( getData( document ) ).to.equal( '[<image alt="fiz buz" src="image.png"></image>]' );
+	} );
+
+	it( 'should allow to provide batch instance', () => {
+		const batch = document.batch();
+		const spy = sinon.spy( batch, 'setAttribute' );
+
+		setData( document, '[<image src="image.png"></image>]' );
+
+		command._doExecute( { newValue: 'foo bar', batch } );
+
+		expect( getData( document ) ).to.equal( '[<image alt="foo bar" src="image.png"></image>]' );
+		sinon.assert.calledOnce( spy );
+	} );
+} );

+ 24 - 0
packages/ckeditor5-image/tests/imagealternatetext/imagelaternatetextengine.js

@@ -0,0 +1,24 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+import ImageAlternateTextEngine from '../../src/imagealternatetext/imagealternatetextengine';
+import ImageAlternateTextCommand from '../../src/imagealternatetext/imagealternatetextcommand';
+
+describe( 'ImageAlternateTextEngine', () => {
+	let editor;
+	beforeEach( () => {
+		return VirtualTestEditor.create( {
+			plugins: [ ImageAlternateTextEngine ]
+		} )
+		.then( newEditor => {
+			editor = newEditor;
+		} );
+	} );
+
+	it( 'should register ImageAlteranteTextCommand', () => {
+		expect( editor.commands.get( 'imageAlternateText' ) ).to.be.instanceOf( ImageAlternateTextCommand );
+	} );
+} );

+ 52 - 0
packages/ckeditor5-image/tests/imagealternatetext/ui/alternatetextformview.js

@@ -0,0 +1,52 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global Event */
+
+import AlternateTextFormView from '../../../src/imagealternatetext/ui/alternatetextformview';
+import View from '@ckeditor/ckeditor5-ui/src/view';
+
+describe( 'AlternateTextFormView', () => {
+	let view;
+
+	beforeEach( () => {
+		view = new AlternateTextFormView( { t: () => {} } );
+
+		view.init();
+	} );
+
+	describe( 'constructor()', () => {
+		it( 'should create element from template', () => {
+			expect( view.element.classList.contains( 'ck-alternate-text-form' ) ).to.be.true;
+		} );
+
+		it( 'should create child views', () => {
+			expect( view.lebeledInput ).to.be.instanceOf( View );
+			expect( view.saveButtonView ).to.be.instanceOf( View );
+			expect( view.cancelButtonView ).to.be.instanceOf( View );
+		} );
+
+		it( 'should fire `cancel` event on cancelButtonView#execute', () => {
+			const spy = sinon.spy();
+			view.on( 'cancel', spy );
+			view.cancelButtonView.fire( 'execute' );
+
+			sinon.assert.calledOnce( spy );
+		} );
+	} );
+
+	describe( 'DOM bindings', () => {
+		describe( 'submit event', () => {
+			it( 'should trigger submit event', () => {
+				const spy = sinon.spy();
+
+				view.on( 'submit', spy );
+				view.element.dispatchEvent( new Event( 'submit' ) );
+
+				expect( spy.calledOnce ).to.true;
+			} );
+		} );
+	} );
+} );

+ 20 - 83
packages/ckeditor5-image/tests/imagetoolbar.js

@@ -3,19 +3,17 @@
  * For licensing, see LICENSE.md.
  */
 
-/* global Event */
-
 import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classic';
 import ImageToolbar from '../src/imagetoolbar';
 import Image from '../src/image';
 import global from '@ckeditor/ckeditor5-utils/src/dom/global';
-import BalloonPanelView from '@ckeditor/ckeditor5-ui/src/balloonpanel/balloonpanelview';
+import ImageBalloonPanel from '../src/ui/imageballoonpanelview';
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
 import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 
 describe( 'ImageToolbar', () => {
-	let editor, button, editingView, doc, panel;
+	let editor, button, editingView, doc, panel, plugin;
 
 	beforeEach( () => {
 		const editorElement = global.document.createElement( 'div' );
@@ -31,7 +29,8 @@ describe( 'ImageToolbar', () => {
 			editor = newEditor;
 			editingView = editor.editing.view;
 			doc = editor.document;
-			panel = getBalloonPanelView( editor.ui.view.body );
+			plugin = editor.plugins.get( ImageToolbar );
+			panel = plugin._panel;
 		} );
 	} );
 
@@ -55,8 +54,7 @@ describe( 'ImageToolbar', () => {
 			plugins: [ ImageToolbar ],
 		} )
 			.then( newEditor => {
-				const viewBody = newEditor.ui.view.body;
-				expect( getBalloonPanelView( viewBody ) ).to.be.undefined;
+				expect( newEditor.plugins.get( ImageToolbar )._panel ).to.be.undefined;
 
 				newEditor.destroy();
 			} );
@@ -70,7 +68,7 @@ describe( 'ImageToolbar', () => {
 			plugins: [ ImageToolbar, FakeButton, AlterDefaultConfig ]
 		} )
 			.then( newEditor => {
-				const panel = getBalloonPanelView( newEditor.ui.view.body );
+				const panel = newEditor.plugins.get( ImageToolbar )._panel;
 				const toolbar = panel.content.get( 0 );
 				const button = toolbar.items.get( 0 );
 
@@ -81,95 +79,34 @@ describe( 'ImageToolbar', () => {
 			} );
 	} );
 
-	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 add ImageBalloonPanel to view body', () => {
+		expect( panel ).to.be.instanceOf( ImageBalloonPanel );
 	} );
 
-	it( 'should calculate panel position on scroll event', () => {
+	it( 'should show the panel when editor gains focus and image is selected', () => {
 		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' ) );
+		editor.ui.focusTracker.isFocused = false;
+		const spy = sinon.spy( plugin, 'show' );
+		editor.ui.focusTracker.isFocused = true;
 
-		sinon.assert.notCalled( spy );
+		sinon.assert.calledOnce( spy );
 	} );
 
-	it( 'should hide the panel when editor looses focus', () => {
-		setData( doc, '[<image src=""></image>]' );
+	it( 'should not show the panel when editor looses focus', () => {
 		editor.ui.focusTracker.isFocused = true;
-		const spy = sinon.spy( panel, 'hide' );
+		const spy = sinon.spy( plugin, 'show' );
 		editor.ui.focusTracker.isFocused = false;
 
-		sinon.assert.calledOnce( spy );
+		sinon.assert.notCalled( 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() );
+	it( 'should detach panel with hide() method', () => {
+		const spy = sinon.spy( panel, 'detach' );
+		plugin.hide();
 
 		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 {

+ 8 - 0
packages/ckeditor5-image/tests/manual/alternatetext.html

@@ -0,0 +1,8 @@
+<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="CKEditor logo" />
+	</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>
+</div>

+ 30 - 0
packages/ckeditor5-image/tests/manual/alternatetext.js

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

+ 10 - 0
packages/ckeditor5-image/tests/manual/alternatetext.md

@@ -0,0 +1,10 @@
+## Image alternate text
+
+* Click on image - balloon toolbar with alternate text change button should appear.
+* Click on button in toolbar:
+  * balloon toolbar should hide, 
+  * alternate image text balloon panel should be visible
+  * there should be `CKEditor logo` text selected inside textarea.
+* Change the text and press `Ok`. Check if alternate text is changed by opening balloon panel again.
+* Change the text and press `Cancel`. Check if alternate text is NOT changed by opening balloon panel again.
+

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

@@ -3,4 +3,4 @@
 * 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.
+* Resize the browser window so the scrollbar is visible. Click on image and scroll editor contents - check if toolbar is placed in proper position.

+ 140 - 0
packages/ckeditor5-image/tests/ui/imageballoonpanelview.js

@@ -0,0 +1,140 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global Event */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classic';
+import global from '@ckeditor/ckeditor5-utils/src/dom/global';
+import ImageBalloonPanel from '../../src/ui/imageballoonpanelview';
+import BalloonPanelView from '@ckeditor/ckeditor5-ui/src/balloonpanel/balloonpanelview';
+import ImageEngine from '../../src/imageengine';
+import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+describe( 'ImageBalloonPanel', () => {
+	let editor, panel, document, editingView;
+
+	beforeEach( () => {
+		const editorElement = global.document.createElement( 'div' );
+		global.document.body.appendChild( editorElement );
+
+		return ClassicEditor.create( editorElement, {
+			plugins: [ ImageEngine ]
+		} )
+			.then( newEditor => {
+				editor = newEditor;
+				panel = new ImageBalloonPanel( editor );
+				document = editor.document;
+				editingView = editor.editing.view;
+
+				return editor.ui.view.body.add( panel );
+			} );
+	} );
+
+	afterEach( () => {
+		return editor.destroy();
+	} );
+
+	it( 'should add element to editor\'s focus tracker', () => {
+		const spy = sinon.spy( editor.ui.focusTracker, 'add' );
+		const panel = new ImageBalloonPanel( editor );
+
+		sinon.assert.calledOnce( spy );
+		sinon.assert.calledWithExactly( spy, panel.element );
+	} );
+
+	it( 'should detach panel when focus is removed', () => {
+		const spy = sinon.spy( panel, 'detach' );
+		editor.ui.focusTracker.isFocused = true;
+		editor.ui.focusTracker.isFocused = false;
+
+		sinon.assert.calledOnce( spy );
+	} );
+
+	it( 'should detach when image element is no longer selected', () => {
+		const spy = sinon.spy( panel, 'detach' );
+		setData( document, '[<image src=""></image>]' );
+		panel.attach();
+
+		document.enqueueChanges( () => {
+			document.selection.removeAllRanges();
+		} );
+
+		sinon.assert.calledOnce( spy );
+	} );
+
+	it( 'should attach panel correctly', () => {
+		const spy = sinon.spy( panel, 'attachTo' );
+		panel.attach();
+
+		testPanelAttach( spy );
+	} );
+
+	it( 'should calculate panel position on scroll event', () => {
+		panel.attach();
+		const spy = sinon.spy( panel, 'attachTo' );
+
+		global.window.dispatchEvent( new Event( 'scroll' ) );
+
+		testPanelAttach( spy );
+	} );
+
+	it( 'should calculate panel position on resize event event', () => {
+		panel.attach();
+		const spy = sinon.spy( panel, 'attachTo' );
+
+		global.window.dispatchEvent( new Event( 'resize' ) );
+
+		testPanelAttach( spy );
+	} );
+
+	it( 'should hide panel on detach', () => {
+		panel.attach();
+		const spy = sinon.spy( panel, 'hide' );
+
+		panel.detach();
+
+		sinon.assert.calledOnce( spy );
+	} );
+
+	it( 'should not reposition panel after detaching', () => {
+		panel.attach();
+		const spy = sinon.spy( panel, 'attachTo' );
+
+		panel.detach();
+		global.window.dispatchEvent( new Event( 'resize' ) );
+		global.window.dispatchEvent( new Event( 'scroll' ) );
+
+		sinon.assert.notCalled( spy );
+	} );
+
+	// 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 );
+	}
+} );

+ 32 - 0
packages/ckeditor5-image/theme/imagealternatetext/theme.scss

@@ -0,0 +1,32 @@
+// Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+// For licensing, see LICENSE.md or http://ckeditor.com/license
+
+@import '~@ckeditor/ckeditor5-theme-lark/theme/helpers/_spacing';
+
+.ck-alternate-text {
+	&-form {
+		padding: ck-spacing( 'large' );
+		overflow: hidden;
+
+		.ck-label {
+			margin-bottom: ck-spacing( 'small' );
+		}
+
+		&__actions {
+			clear: both;
+			padding-top: ck-spacing( 'large' );
+
+			.ck-button {
+				float: right;
+
+				& + .ck-button {
+					margin-right: ck-spacing( 'medium' );
+
+					& + .ck-button {
+						float: left;
+					}
+				}
+			}
+		}
+	}
+}

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

@@ -30,4 +30,3 @@
 		margin-left: 0.8em;
 	}
 }
-