Browse Source

Merge pull request #125 from ckeditor/t/96

Type: Integrated the Image feature with the `ContextualBalloon` plugin. Closes #96. Closes #106.
Szymon Kupś 8 years ago
parent
commit
29f12104a7

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

@@ -1,102 +0,0 @@
-/**
- * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-/**
- * @module image/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/panel/balloon/balloonpanelview';
-import { isImageWidget } from '../utils';
-
-/**
- * Image balloon panel class. It extends {module:ui/panel/balloon/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/panel/balloon/balloonpanelview~BalloonPanelView
- */
-export default class ImageBalloonPanelView extends BalloonPanelView {
-	/**
-	 * @inheritDoc
-	 */
-	constructor( editor ) {
-		super( editor.locale );
-
-		this.editor = editor;
-		const editingView = editor.editing.view;
-
-		// 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 );
-	}
-
-	/**
-	 * @inheritDoc
-	 */
-	init() {
-		// Let the focusTracker know about new focusable UI element.
-		this.editor.ui.focusTracker.add( this.element );
-
-		super.init();
-	}
-
-	/**
-	 * 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;
-		const defaultPositions = BalloonPanelView.defaultPositions;
-
-		this.attachTo( {
-			target: editingView.domConverter.viewToDom( editingView.selection.getSelectedElement() ),
-			positions: [ defaultPositions.northArrowSouth, defaultPositions.southArrowNorth ]
-		} );
-	}
-}

+ 51 - 0
packages/ckeditor5-image/src/image/ui/utils.js

@@ -0,0 +1,51 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module image/image/ui/utils
+ */
+
+import BalloonPanelView from '@ckeditor/ckeditor5-ui/src/panel/balloon/balloonpanelview';
+import { isImageWidget } from '../utils';
+
+/**
+ * A helper utility which positions the
+ * {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon} instance
+ * with respect to the image in the editor's content, if one is selected.
+ *
+ * @param {module:core/editor/editor~Editor} editor Editor instance.
+ */
+export function repositionContextualBalloon( editor ) {
+	const editingView = editor.editing.view;
+	const balloon = editor.plugins.get( 'ContextualBalloon' );
+	const selectedElement = editingView.selection.getSelectedElement();
+
+	if ( selectedElement && isImageWidget( selectedElement ) ) {
+		const position = getBalloonPositionData( editor );
+
+		balloon.updatePosition( position );
+	}
+}
+
+/**
+ * Returns the positioning options that control the geometry of the
+ * {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon}, with respect
+ * to the selected element in the editor content.
+ *
+ * @param {module:core/editor/editor~Editor} editor Editor instance.
+ * @returns {module:utils/dom/position~Options}
+ */
+export function getBalloonPositionData( editor ) {
+	const editingView = editor.editing.view;
+	const defaultPositions = BalloonPanelView.defaultPositions;
+
+	return {
+		target: editingView.domConverter.viewToDom( editingView.selection.getSelectedElement() ),
+		positions: [
+			defaultPositions.northArrowSouth,
+			defaultPositions.southArrowNorth
+		]
+	};
+}

+ 94 - 69
packages/ckeditor5-image/src/imagetextalternative.js

@@ -10,18 +10,19 @@
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
 import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
 import ImageTextAlternativeEngine from './imagetextalternative/imagetextalternativeengine';
 import ImageTextAlternativeEngine from './imagetextalternative/imagetextalternativeengine';
-import escPressHandler from '@ckeditor/ckeditor5-ui/src/bindings/escpresshandler';
 import clickOutsideHandler from '@ckeditor/ckeditor5-ui/src/bindings/clickoutsidehandler';
 import clickOutsideHandler from '@ckeditor/ckeditor5-ui/src/bindings/clickoutsidehandler';
-import ImageToolbar from './imagetoolbar';
 import TextAlternativeFormView from './imagetextalternative/ui/textalternativeformview';
 import TextAlternativeFormView from './imagetextalternative/ui/textalternativeformview';
-import ImageBalloonPanel from './image/ui/imageballoonpanelview';
-
+import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon';
 import textAlternativeIcon from '@ckeditor/ckeditor5-core/theme/icons/low-vision.svg';
 import textAlternativeIcon from '@ckeditor/ckeditor5-core/theme/icons/low-vision.svg';
+import { repositionContextualBalloon, getBalloonPositionData } from './image/ui/utils';
+
 import '../theme/imagetextalternative/theme.scss';
 import '../theme/imagetextalternative/theme.scss';
 
 
 /**
 /**
  * The image text alternative plugin.
  * The image text alternative plugin.
  *
  *
+ * The plugin uses the {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon}.
+ *
  * @extends module:core/plugin~Plugin
  * @extends module:core/plugin~Plugin
  */
  */
 export default class ImageTextAlternative extends Plugin {
 export default class ImageTextAlternative extends Plugin {
@@ -29,7 +30,7 @@ export default class ImageTextAlternative extends Plugin {
 	 * @inheritDoc
 	 * @inheritDoc
 	 */
 	 */
 	static get requires() {
 	static get requires() {
-		return [ ImageTextAlternativeEngine ];
+		return [ ImageTextAlternativeEngine, ContextualBalloon ];
 	}
 	}
 
 
 	/**
 	/**
@@ -44,22 +45,7 @@ export default class ImageTextAlternative extends Plugin {
 	 */
 	 */
 	init() {
 	init() {
 		this._createButton();
 		this._createButton();
-
-		const panel = this._createBalloonPanel();
-
-		/**
-		 * Balloon panel containing text alternative change form.
-		 *
-		 * @member {module:image/image/ui/imageballoonpanel~ImageBalloonPanelView} #baloonPanel
-		 */
-		this.balloonPanel = panel;
-
-		/**
-		 * Form containing textarea and buttons, used to change `alt` text value.
-		 *
-		 * @member {module:image/imagetextalternative/ui/textalternativeformview~TextAlternativeFormView} #form
-		 */
-		this.form = panel.content.get( 0 );
+		this._createForm();
 	}
 	}
 
 
 	/**
 	/**
@@ -84,77 +70,98 @@ export default class ImageTextAlternative extends Plugin {
 
 
 			view.bind( 'isEnabled' ).to( command, 'isEnabled' );
 			view.bind( 'isEnabled' ).to( command, 'isEnabled' );
 
 
-			this.listenTo( view, 'execute', () => this._showBalloonPanel() );
+			this.listenTo( view, 'execute', () => this._showForm() );
 
 
 			return view;
 			return view;
 		} );
 		} );
 	}
 	}
 
 
 	/**
 	/**
-	 * Creates balloon panel.
+	 * Creates the {@link module:image/imagetextalternative/ui/textalternativeformview~TextAlternativeFormView}
+	 * form.
 	 *
 	 *
 	 * @private
 	 * @private
-	 * @return {module:image/image/ui/imageballoonpanel~ImageBalloonPanelView}
 	 */
 	 */
-	_createBalloonPanel() {
+	_createForm() {
 		const editor = this.editor;
 		const editor = this.editor;
+		const editingView = editor.editing.view;
 
 
-		const panel = new ImageBalloonPanel( editor );
-		const form = new TextAlternativeFormView( editor.locale );
+		/**
+		 * The contextual balloon plugin instance.
+		 *
+		 * @private
+		 * @member {module:ui/panel/balloon/contextualballoon~ContextualBalloon}
+		 */
+		this._balloon = this.editor.plugins.get( 'ContextualBalloon' );
 
 
-		this.listenTo( form, 'submit', () => {
-			editor.execute( 'imageTextAlternative', { newValue: form.labeledInput.inputView.element.value } );
-			this._hideBalloonPanel();
-		} );
+		/**
+		 * Form containing textarea and buttons, used to change `alt` text value.
+		 *
+		 * @member {module:image/imagetextalternative/ui/textalternativeformview~TextAlternativeFormView} #form
+		 */
+		this._form = new TextAlternativeFormView( editor.locale );
 
 
-		// If image toolbar is present - hide it when text alternative balloon is visible.
-		const imageToolbar = editor.plugins.get( ImageToolbar );
-
-		if ( imageToolbar ) {
-			this.listenTo( panel, 'change:isVisible', () => {
-				if ( panel.isVisible ) {
-					imageToolbar.hide();
-					imageToolbar.isEnabled = false;
-				} else {
-					imageToolbar.show();
-					imageToolbar.isEnabled = true;
-				}
+		this.listenTo( this._form, 'submit', () => {
+			editor.execute( 'imageTextAlternative', {
+				newValue: this._form.labeledInput.inputView.element.value
 			} );
 			} );
-		}
 
 
-		this.listenTo( form, 'cancel', () => this._hideBalloonPanel() );
+			this._hideForm( true );
+		} );
 
 
-		// Close on `ESC` press.
-		escPressHandler( {
-			emitter: panel,
-			activator: () => panel.isVisible,
-			callback: () => this._hideBalloonPanel()
+		this.listenTo( this._form, 'cancel', () => {
+			this._hideForm( true );
 		} );
 		} );
 
 
-		// Close on click outside of balloon panel element.
-		clickOutsideHandler( {
-			emitter: panel,
-			activator: () => panel.isVisible,
-			contextElements: [ panel.element ],
-			callback: () => this._hideBalloonPanel()
+		// Close the form on Esc key press.
+		this._form.keystrokes.set( 'Esc', ( data, cancel ) => {
+			this._hideForm( true );
+			cancel();
 		} );
 		} );
 
 
-		panel.content.add( form );
-		editor.ui.view.body.add( panel );
+		// Reposition the balloon upon #render.
+		this.listenTo( editingView, 'render', () => {
+			if ( this._isVisible ) {
+				repositionContextualBalloon( editor );
+			}
+		}, { priority: 'low' } );
 
 
-		return panel;
+		// Hide the form when the editor is blurred.
+		this.listenTo( editor.ui.focusTracker, 'change:isFocused', ( evt, name, is ) => {
+			if ( !is ) {
+				this._hideForm();
+			}
+		}, { priority: 'low' } );
+
+		// Close on click outside of balloon panel element.
+		clickOutsideHandler( {
+			emitter: this._form,
+			activator: () => this._isVisible,
+			contextElements: [ this._form.element ],
+			callback: () => this._hideForm()
+		} );
 	}
 	}
 
 
 	/**
 	/**
-	 * Shows the balloon panel.
+	 * Shows the {@link #_form} in the {@link #_balloon}.
 	 *
 	 *
 	 * @private
 	 * @private
 	 */
 	 */
-	_showBalloonPanel() {
+	_showForm() {
+		if ( this._isVisible ) {
+			return;
+		}
+
 		const editor = this.editor;
 		const editor = this.editor;
 		const command = editor.commands.get( 'imageTextAlternative' );
 		const command = editor.commands.get( 'imageTextAlternative' );
-		const labeledInput = this.form.labeledInput;
-		this.balloonPanel.attach();
+		const labeledInput = this._form.labeledInput;
+
+		if ( !this._balloon.hasView( this._form ) ) {
+			this._balloon.add( {
+				view: this._form,
+				position: getBalloonPositionData( editor )
+			} );
+		}
 
 
 		// Make sure that each time the panel shows up, the field remains in sync with the value of
 		// Make sure that each time the panel shows up, the field remains in sync with the value of
 		// the command. If the user typed in the input, then canceled the balloon (`labeledInput#value`
 		// the command. If the user typed in the input, then canceled the balloon (`labeledInput#value`
@@ -163,17 +170,35 @@ export default class ImageTextAlternative extends Plugin {
 		// https://github.com/ckeditor/ckeditor5-image/issues/114
 		// https://github.com/ckeditor/ckeditor5-image/issues/114
 		labeledInput.value = labeledInput.inputView.element.value = command.value || '';
 		labeledInput.value = labeledInput.inputView.element.value = command.value || '';
 
 
-		this.form.labeledInput.select();
+		this._form.labeledInput.select();
 	}
 	}
 
 
 	/**
 	/**
-	 * Hides the balloon panel.
+	 * Removes the {@link #_form} from the {@link #_balloon}.
 	 *
 	 *
+	 * @param {Boolean} focusEditable Control whether the editing view is focused afterwards.
 	 * @private
 	 * @private
 	 */
 	 */
-	_hideBalloonPanel() {
-		const editor = this.editor;
-		this.balloonPanel.detach();
-		editor.editing.view.focus();
+	_hideForm( focusEditable ) {
+		if ( !this._isVisible ) {
+			return;
+		}
+
+		this._balloon.remove( this._form );
+
+		if ( focusEditable ) {
+			this.editor.editing.view.focus();
+		}
+	}
+
+	/**
+	 * Returns `true` when the {@link #_form} is the visible view
+	 * in the {@link #_balloon}.
+	 *
+	 * @private
+	 * @type {Boolean}
+	 */
+	get _isVisible() {
+		return this._balloon.visibleView == this._form;
 	}
 	}
 }
 }

+ 18 - 0
packages/ckeditor5-image/src/imagetextalternative/ui/textalternativeformview.js

@@ -13,6 +13,7 @@ import Template from '@ckeditor/ckeditor5-ui/src/template';
 import LabeledInputView from '@ckeditor/ckeditor5-ui/src/labeledinput/labeledinputview';
 import LabeledInputView from '@ckeditor/ckeditor5-ui/src/labeledinput/labeledinputview';
 import InputTextView from '@ckeditor/ckeditor5-ui/src/inputtext/inputtextview';
 import InputTextView from '@ckeditor/ckeditor5-ui/src/inputtext/inputtextview';
 import submitHandler from '@ckeditor/ckeditor5-ui/src/bindings/submithandler';
 import submitHandler from '@ckeditor/ckeditor5-ui/src/bindings/submithandler';
+import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler';
 
 
 /**
 /**
  * TextAlternativeFormView class.
  * TextAlternativeFormView class.
@@ -29,6 +30,14 @@ export default class TextAlternativeFormView extends View {
 		const t = this.locale.t;
 		const t = this.locale.t;
 
 
 		/**
 		/**
+		 * Instance of the {@link module:utils/keystrokehandler~KeystrokeHandler}.
+		 *
+		 * @readonly
+		 * @member {module:utils/keystrokehandler~KeystrokeHandler}
+		 */
+		this.keystrokes = new KeystrokeHandler();
+
+		/**
 		 * Text area with label.
 		 * Text area with label.
 		 *
 		 *
 		 * @member {module:ui/labeledinput/labeledinputview~LabeledInputView} #labeledTextarea
 		 * @member {module:ui/labeledinput/labeledinputview~LabeledInputView} #labeledTextarea
@@ -92,6 +101,15 @@ export default class TextAlternativeFormView extends View {
 	}
 	}
 
 
 	/**
 	/**
+	 * @inheritDoc
+	 */
+	init() {
+		super.init();
+
+		this.keystrokes.listenTo( this.element );
+	}
+
+	/**
 	 * Creates button view.
 	 * Creates button view.
 	 *
 	 *
 	 * @private
 	 * @private

+ 92 - 50
packages/ckeditor5-image/src/imagetoolbar.js

@@ -10,37 +10,35 @@
 import Template from '@ckeditor/ckeditor5-ui/src/template';
 import Template from '@ckeditor/ckeditor5-ui/src/template';
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import ToolbarView from '@ckeditor/ckeditor5-ui/src/toolbar/toolbarview';
 import ToolbarView from '@ckeditor/ckeditor5-ui/src/toolbar/toolbarview';
+import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon';
 import { isImageWidget } from './image/utils';
 import { isImageWidget } from './image/utils';
-import ImageBalloonPanel from './image/ui/imageballoonpanelview';
+import { repositionContextualBalloon, getBalloonPositionData } from './image/ui/utils';
+
+const balloonClassName = 'ck-toolbar-container ck-editor-toolbar-container';
 
 
 /**
 /**
- * Image toolbar class. Creates image toolbar placed inside balloon panel that is showed when image widget is selected.
+ * The image toolbar class. Creates an image toolbar that shows up when image widget is selected.
+ *
  * Toolbar components are created using editor's {@link module:ui/componentfactory~ComponentFactory ComponentFactory}
  * 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`.
  * based on {@link module:core/editor/editor~Editor#config configuration} stored under `image.toolbar`.
  *
  *
+ * The toolbar uses {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon}.
+ *
  * @extends module:core/plugin~Plugin
  * @extends module:core/plugin~Plugin
  */
  */
 export default class ImageToolbar extends Plugin {
 export default class ImageToolbar extends Plugin {
 	/**
 	/**
 	 * @inheritDoc
 	 * @inheritDoc
 	 */
 	 */
-	static get pluginName() {
-		return 'ImageToolbar';
+	static get requires() {
+		return [ ContextualBalloon ];
 	}
 	}
 
 
 	/**
 	/**
 	 * @inheritDoc
 	 * @inheritDoc
 	 */
 	 */
-	constructor( editor ) {
-		super( editor );
-
-		/**
-		 * When set to `true`, toolbar will be repositioned and showed on each render event and focus change.
-		 * Set to `false` to temporary disable the image toolbar.
-		 *
-		 * @member {Boolean}
-		 */
-		this.isEnabled = true;
+	static get pluginName() {
+		return 'ImageToolbar';
 	}
 	}
 
 
 	/**
 	/**
@@ -55,65 +53,109 @@ export default class ImageToolbar extends Plugin {
 			return;
 			return;
 		}
 		}
 
 
-		const panel = this._panel = new ImageBalloonPanel( editor );
-		const toolbar = new ToolbarView();
+		/**
+		 * The contextual balloon plugin instance.
+		 *
+		 * @private
+		 * @member {module:ui/panel/balloon/contextualballoon~ContextualBalloon}
+		 */
+		this._balloon = this.editor.plugins.get( 'ContextualBalloon' );
+
+		/**
+		 * A `ToolbarView` instance used to display the buttons specific for image
+		 * editing.
+		 *
+		 * @protected
+		 * @type {module:ui/toolbar/toolbarview~ToolbarView}
+		 */
+		this._toolbar = new ToolbarView();
 
 
 		// Add CSS class to the toolbar.
 		// Add CSS class to the toolbar.
-		Template.extend( toolbar.template, {
+		Template.extend( this._toolbar.template, {
 			attributes: {
 			attributes: {
 				class: 'ck-editor-toolbar'
 				class: 'ck-editor-toolbar'
 			}
 			}
 		} );
 		} );
 
 
-		// Add CSS class to the panel.
-		Template.extend( panel.template, {
-			attributes: {
-				class: [
-					'ck-toolbar-container',
-					'ck-editor-toolbar-container'
-				]
-			}
-		} );
-
-		// Add toolbar to balloon panel.
-		panel.content.add( toolbar );
-
 		// Add buttons to the toolbar.
 		// Add buttons to the toolbar.
-		toolbar.fillFromConfig( toolbarConfig, editor.ui.componentFactory );
-
-		// Add balloon panel to editor's UI.
-		editor.ui.view.body.add( panel );
+		this._toolbar.fillFromConfig( toolbarConfig, editor.ui.componentFactory );
 
 
 		// Show balloon panel each time image widget is selected.
 		// Show balloon panel each time image widget is selected.
-		this.listenTo( this.editor.editing.view, 'render', () => {
-			if ( this.isEnabled ) {
-				this.show();
-			}
+		this.listenTo( editor.editing.view, 'render', () => {
+			this._checkIsVisible();
 		}, { priority: 'low' } );
 		}, { priority: 'low' } );
 
 
 		// There is no render method after focus is back in editor, we need to check if balloon panel should be visible.
 		// 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.isEnabled ) {
-				this.show();
+		this.listenTo( editor.ui.focusTracker, 'change:isFocused', () => {
+			this._checkIsVisible();
+		}, { priority: 'low' } );
+	}
+
+	/**
+	 * Checks whether the toolbar should show up or hide depending on the
+	 * current selection.
+	 *
+	 * @private
+	 */
+	_checkIsVisible() {
+		const editor = this.editor;
+
+		if ( !editor.ui.focusTracker.isFocused ) {
+			this._hideToolbar();
+		} else {
+			const editingView = editor.editing.view;
+			const selectedElement = editingView.selection.getSelectedElement();
+
+			if ( selectedElement && isImageWidget( selectedElement ) ) {
+				this._showToolbar();
+			} else {
+				this._hideToolbar();
 			}
 			}
-		} );
+		}
 	}
 	}
 
 
 	/**
 	/**
-	 * Shows the toolbar.
+	 * Shows the {@link #_toolbar} in the {@link #_balloon}.
+	 *
+	 * @private
 	 */
 	 */
-	show() {
-		const selectedElement = this.editor.editing.view.selection.getSelectedElement();
+	_showToolbar() {
+		const editor = this.editor;
 
 
-		if ( selectedElement && isImageWidget( selectedElement ) ) {
-			this._panel.attach();
+		if ( this._isVisible ) {
+			repositionContextualBalloon( editor );
+		} else {
+			if ( !this._balloon.hasView( this._toolbar ) ) {
+				this._balloon.add( {
+					view: this._toolbar,
+					position: getBalloonPositionData( editor ),
+					balloonClassName
+				} );
+			}
 		}
 		}
 	}
 	}
 
 
 	/**
 	/**
-	 * Hides the toolbar.
+	 * Removes the {@link #_toolbar} from the {@link #_balloon}.
+	 *
+	 * @private
+	 */
+	_hideToolbar() {
+		if ( !this._isVisible ) {
+			return;
+		}
+
+		this._balloon.remove( this._toolbar );
+	}
+
+	/**
+	 * Returns `true` when the {@link #_toolbar} is the visible view
+	 * in the {@link #_balloon}.
+	 *
+	 * @private
+	 * @type {Boolean}
 	 */
 	 */
-	hide() {
-		this._panel.detach();
+	get _isVisible() {
+		return this._balloon.visibleView == this._toolbar;
 	}
 	}
 }
 }

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

@@ -1,145 +0,0 @@
-/**
- * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md.
- */
-
-/* global window, document, Event */
-
-import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
-
-import ImageEngine from '../../../src/image/imageengine';
-import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
-
-import ImageBalloonPanel from '../../../src/image/ui/imageballoonpanelview';
-import BalloonPanelView from '@ckeditor/ckeditor5-ui/src/panel/balloon/balloonpanelview';
-
-import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
-
-describe( 'ImageBalloonPanel', () => {
-	let editor, panel, doc, editorElement;
-
-	beforeEach( () => {
-		editorElement = document.createElement( 'div' );
-		document.body.appendChild( editorElement );
-
-		return ClassicEditor
-			.create( editorElement, {
-				plugins: [ ImageEngine, Paragraph ]
-			} )
-			.then( newEditor => {
-				editor = newEditor;
-				panel = new ImageBalloonPanel( editor );
-				doc = editor.document;
-
-				return editor.ui.view.body.add( panel );
-			} );
-	} );
-
-	afterEach( () => {
-		editorElement.remove();
-
-		return editor.destroy();
-	} );
-
-	it( 'should add element to editor\'s focus tracker after init', () => {
-		const spy = sinon.spy( editor.ui.focusTracker, 'add' );
-		const panel = new ImageBalloonPanel( editor );
-
-		sinon.assert.notCalled( spy );
-
-		panel.init();
-		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( doc, '<paragraph>foo</paragraph>[<image src=""></image>]' );
-		panel.attach();
-
-		doc.enqueueChanges( () => {
-			doc.selection.removeAllRanges();
-		} );
-
-		sinon.assert.calledOnce( spy );
-	} );
-
-	it( 'should attach panel correctly', () => {
-		const spy = sinon.spy( panel, 'attachTo' );
-
-		setData( doc, '[<image src=""></image>]' );
-		panel.attach();
-
-		testPanelAttach( spy );
-	} );
-
-	it( 'should calculate panel position on scroll event', () => {
-		setData( doc, '[<image src=""></image>]' );
-		panel.attach();
-
-		const spy = sinon.spy( panel, 'attachTo' );
-
-		window.dispatchEvent( new Event( 'scroll' ) );
-
-		testPanelAttach( spy );
-	} );
-
-	it( 'should calculate panel position on resize event event', () => {
-		setData( doc, '[<image src=""></image>]' );
-		panel.attach();
-
-		const spy = sinon.spy( panel, 'attachTo' );
-
-		window.dispatchEvent( new Event( 'resize' ) );
-
-		testPanelAttach( spy );
-	} );
-
-	it( 'should hide panel on detach', () => {
-		setData( doc, '[<image src=""></image>]' );
-		panel.attach();
-
-		const spy = sinon.spy( panel, 'hide' );
-
-		panel.detach();
-
-		sinon.assert.calledOnce( spy );
-	} );
-
-	it( 'should not reposition panel after detaching', () => {
-		setData( doc, '[<image src=""></image>]' );
-		panel.attach();
-
-		const spy = sinon.spy( panel, 'attachTo' );
-
-		panel.detach();
-		window.dispatchEvent( new Event( 'resize' ) );
-		window.dispatchEvent( new Event( 'scroll' ) );
-
-		sinon.assert.notCalled( spy );
-	} );
-
-	// Tests if panel.attachTo() was called correctly.
-	function testPanelAttach( spy ) {
-		sinon.assert.calledOnce( spy );
-		const options = spy.firstCall.args[ 0 ];
-
-		// Check if proper target element was used.
-		expect( options.target.tagName.toLowerCase() ).to.equal( 'figure' );
-
-		// Check if correct positions are used.
-		const [ north, south ] = options.positions;
-
-		expect( north ).to.equal( BalloonPanelView.defaultPositions.northArrowSouth );
-		expect( south ).to.equal( BalloonPanelView.defaultPositions.southArrowNorth );
-	}
-} );

+ 93 - 0
packages/ckeditor5-image/tests/image/ui/utils.js

@@ -0,0 +1,93 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import Image from '../../../src/image';
+import global from '@ckeditor/ckeditor5-utils/src/dom/global';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import View from '@ckeditor/ckeditor5-ui/src/view';
+import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon';
+import BalloonPanelView from '@ckeditor/ckeditor5-ui/src/panel/balloon/balloonpanelview';
+import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { repositionContextualBalloon, getBalloonPositionData } from '../../../src/image/ui/utils';
+
+describe( 'Utils', () => {
+	let editor, doc, editingView, balloon, editorElement;
+
+	beforeEach( () => {
+		editorElement = global.document.createElement( 'div' );
+		global.document.body.appendChild( editorElement );
+
+		return ClassicEditor.create( editorElement, {
+			plugins: [ Image, Paragraph, ContextualBalloon ]
+		} )
+		.then( newEditor => {
+			editor = newEditor;
+			doc = editor.document;
+			editingView = editor.editing.view;
+			balloon = editor.plugins.get( 'ContextualBalloon' );
+		} );
+	} );
+
+	afterEach( () => {
+		editorElement.remove();
+
+		return editor.destroy();
+	} );
+
+	describe( 'repositionContextualBalloon', () => {
+		it( 'should re-position the ContextualBalloon when the image is selected', () => {
+			const spy = sinon.spy( balloon, 'updatePosition' );
+			const defaultPositions = BalloonPanelView.defaultPositions;
+			const view = new View();
+
+			view.element = global.document.createElement( 'div' );
+
+			balloon.add( {
+				view,
+				position: {
+					target: global.document.body
+				}
+			} );
+
+			setData( doc, '[<image src=""></image>]' );
+			repositionContextualBalloon( editor );
+
+			sinon.assert.calledWithExactly( spy, {
+				target: editingView.domConverter.viewToDom( editingView.selection.getSelectedElement() ),
+				positions: [
+					defaultPositions.northArrowSouth,
+					defaultPositions.southArrowNorth
+				]
+			} );
+		} );
+
+		it( 'should not engage with no image is selected', () => {
+			const spy = sinon.spy( balloon, 'updatePosition' );
+
+			setData( doc, '<paragraph>foo</paragraph>' );
+
+			repositionContextualBalloon( editor );
+			sinon.assert.notCalled( spy );
+		} );
+	} );
+
+	describe( 'getBalloonPositionData', () => {
+		it( 'returns the position data', () => {
+			const defaultPositions = BalloonPanelView.defaultPositions;
+
+			setData( doc, '[<image src=""></image>]' );
+			const data = getBalloonPositionData( editor );
+
+			expect( data ).to.deep.equal( {
+				target: editingView.domConverter.viewToDom( editingView.selection.getSelectedElement() ),
+				positions: [
+					defaultPositions.northArrowSouth,
+					defaultPositions.southArrowNorth
+				]
+			} );
+		} );
+	} );
+} );

+ 109 - 118
packages/ckeditor5-image/tests/imagetextalternative.js

@@ -3,23 +3,23 @@
  * For licensing, see LICENSE.md.
  * For licensing, see LICENSE.md.
  */
  */
 
 
+/* global Event, document */
+
 import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
 import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
 import Image from '../src/image';
 import Image from '../src/image';
-import ImageToolbar from '../src/imagetoolbar';
 import ImageTextAlternative from '../src/imagetextalternative';
 import ImageTextAlternative from '../src/imagetextalternative';
 import ImageTextAlternativeEngine from '../src/imagetextalternative/imagetextalternativeengine';
 import ImageTextAlternativeEngine from '../src/imagetextalternative/imagetextalternativeengine';
 import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
 import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+import View from '@ckeditor/ckeditor5-ui/src/view';
 import global from '@ckeditor/ckeditor5-utils/src/dom/global';
 import global from '@ckeditor/ckeditor5-utils/src/dom/global';
 import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
 import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
 
 
-/* global Event */
-
 describe( 'ImageTextAlternative', () => {
 describe( 'ImageTextAlternative', () => {
-	let editor, plugin, command, balloonPanel, form;
+	let editor, editingView, doc, plugin, command, form, balloon, editorElement, button;
 
 
 	beforeEach( () => {
 	beforeEach( () => {
-		const editorElement = global.document.createElement( 'div' );
+		editorElement = global.document.createElement( 'div' );
 		global.document.body.appendChild( editorElement );
 		global.document.body.appendChild( editorElement );
 
 
 		return ClassicTestEditor.create( editorElement, {
 		return ClassicTestEditor.create( editorElement, {
@@ -27,15 +27,20 @@ describe( 'ImageTextAlternative', () => {
 		} )
 		} )
 		.then( newEditor => {
 		.then( newEditor => {
 			editor = newEditor;
 			editor = newEditor;
+			editingView = editor.editing.view;
+			doc = editor.document;
 			newEditor.editing.view.attachDomRoot( editorElement );
 			newEditor.editing.view.attachDomRoot( editorElement );
 			plugin = editor.plugins.get( ImageTextAlternative );
 			plugin = editor.plugins.get( ImageTextAlternative );
 			command = editor.commands.get( 'imageTextAlternative' );
 			command = editor.commands.get( 'imageTextAlternative' );
-			balloonPanel = plugin.balloonPanel;
-			form = plugin.form;
+			form = plugin._form;
+			balloon = editor.plugins.get( 'ContextualBalloon' );
+			button = editor.ui.componentFactory.create( 'imageTextAlternative' );
 		} );
 		} );
 	} );
 	} );
 
 
 	afterEach( () => {
 	afterEach( () => {
+		editorElement.remove();
+
 		return editor.destroy();
 		return editor.destroy();
 	} );
 	} );
 
 
@@ -48,12 +53,6 @@ describe( 'ImageTextAlternative', () => {
 	} );
 	} );
 
 
 	describe( 'toolbar button', () => {
 	describe( 'toolbar button', () => {
-		let button;
-
-		beforeEach( () => {
-			button = editor.ui.componentFactory.create( 'imageTextAlternative' );
-		} );
-
 		it( 'should be registered in component factory', () => {
 		it( 'should be registered in component factory', () => {
 			expect( button ).to.be.instanceOf( ButtonView );
 			expect( button ).to.be.instanceOf( ButtonView );
 		} );
 		} );
@@ -67,29 +66,37 @@ describe( 'ImageTextAlternative', () => {
 		} );
 		} );
 
 
 		it( 'should show balloon panel on execute', () => {
 		it( 'should show balloon panel on execute', () => {
-			const spy = sinon.spy( balloonPanel, 'attach' );
-			setData( editor.document, '[<image src="" alt="foo bar"></image>]' );
+			expect( balloon.visibleView ).to.be.null;
+
+			setData( doc, '[<image src="" alt="foo bar"></image>]' );
+
 			button.fire( 'execute' );
 			button.fire( 'execute' );
+			expect( balloon.visibleView ).to.equal( form );
 
 
-			sinon.assert.calledOnce( spy );
+			// Make sure successive execute does not throw, e.g. attempting
+			// to display the form twice.
+			button.fire( 'execute' );
+			expect( balloon.visibleView ).to.equal( form );
 		} );
 		} );
 
 
 		it( 'should set alt attribute value to textarea and select it', () => {
 		it( 'should set alt attribute value to textarea and select it', () => {
 			const spy = sinon.spy( form.labeledInput, 'select' );
 			const spy = sinon.spy( form.labeledInput, 'select' );
-			setData( editor.document, '[<image src="" alt="foo bar"></image>]' );
-			button.fire( 'execute' );
 
 
+			setData( doc, '[<image src="" alt="foo bar"></image>]' );
+
+			button.fire( 'execute' );
 			sinon.assert.calledOnce( spy );
 			sinon.assert.calledOnce( spy );
-			expect( plugin.form.labeledInput.value ).equals( 'foo bar' );
+			expect( form.labeledInput.value ).equals( 'foo bar' );
 		} );
 		} );
 
 
 		it( 'should set empty text to textarea and select it when there is no alt attribute', () => {
 		it( 'should set empty text to textarea and select it when there is no alt attribute', () => {
 			const spy = sinon.spy( form.labeledInput, 'select' );
 			const spy = sinon.spy( form.labeledInput, 'select' );
-			setData( editor.document, '[<image src=""></image>]' );
-			button.fire( 'execute' );
 
 
+			setData( doc, '[<image src=""></image>]' );
+
+			button.fire( 'execute' );
 			sinon.assert.calledOnce( spy );
 			sinon.assert.calledOnce( spy );
-			expect( plugin.form.labeledInput.value ).equals( '' );
+			expect( form.labeledInput.value ).equals( '' );
 		} );
 		} );
 	} );
 	} );
 
 
@@ -106,7 +113,7 @@ describe( 'ImageTextAlternative', () => {
 			form.labeledInput.inputView.element.value = 'This value was canceled.';
 			form.labeledInput.inputView.element.value = 'This value was canceled.';
 
 
 			// Mock the user editing the same image once again.
 			// Mock the user editing the same image once again.
-			setData( editor.document, '[<image src="" alt="foo"></image>]' );
+			setData( doc, '[<image src="" alt="foo"></image>]' );
 
 
 			button.fire( 'execute' );
 			button.fire( 'execute' );
 			expect( form.labeledInput.inputView.element.value ).to.equal( 'foo' );
 			expect( form.labeledInput.inputView.element.value ).to.equal( 'foo' );
@@ -117,126 +124,110 @@ describe( 'ImageTextAlternative', () => {
 			form.fire( 'submit' );
 			form.fire( 'submit' );
 
 
 			sinon.assert.calledOnce( spy );
 			sinon.assert.calledOnce( spy );
-			sinon.assert.calledWithExactly( spy, 'imageTextAlternative', { newValue: form.labeledInput.inputView.element.value } );
+			sinon.assert.calledWithExactly( spy, 'imageTextAlternative', {
+				newValue: form.labeledInput.inputView.element.value
+			} );
 		} );
 		} );
 
 
-		it( 'should detach panel on cancel', () => {
-			const spy = sinon.spy( balloonPanel, 'detach' );
+		it( 'should hide the panel on cancel and focus the editing view', () => {
+			const spy = sinon.spy( editor.editing.view, 'focus' );
+
+			setData( doc, '[<image src="" alt="foo bar"></image>]' );
+
+			editor.ui.componentFactory.create( 'imageTextAlternative' ).fire( 'execute' );
+			expect( balloon.visibleView ).to.equal( form );
+
 			form.fire( 'cancel' );
 			form.fire( 'cancel' );
+			expect( balloon.visibleView ).to.be.null;
+			sinon.assert.calledOnce( spy );
+		} );
+
+		it( 'should not engage when the form is in the balloon yet invisible', () => {
+			setData( doc, '[<image src=""></image>]' );
+			button.fire( 'execute' );
+			expect( balloon.visibleView ).to.equal( form );
 
 
-			sinon.assert.called( spy );
+			const lastView = new View();
+			lastView.element = document.createElement( 'div' );
+
+			balloon.add( { view: lastView } );
+			expect( balloon.visibleView ).to.equal( lastView );
+
+			button.fire( 'execute' );
+			expect( balloon.visibleView ).to.equal( lastView );
 		} );
 		} );
 
 
-		it( 'should show ImageToolbar on cancel if present', () => {
-			const editorElement = global.document.createElement( 'div' );
-			global.document.body.appendChild( editorElement );
-
-			return ClassicTestEditor.create( editorElement, {
-				plugins: [ ImageTextAlternative, Image, ImageToolbar ],
-				image: {
-					toolbar: [ 'imageTextAlternative' ]
-				}
-			} )
-			.then( newEditor => {
-				newEditor.editing.view.attachDomRoot( editorElement );
-				const plugin = newEditor.plugins.get( ImageTextAlternative );
-				const toolbarPlugin = newEditor.plugins.get( ImageToolbar );
-				const form = plugin.form;
-
-				const spy = sinon.spy( toolbarPlugin, 'show' );
-				form.fire( 'cancel' );
-
-				sinon.assert.called( spy );
+		describe( 'integration with the editor selection (#render event)', () => {
+			it( 'should re-position the form', () => {
+				setData( doc, '[<image src=""></image>]' );
+				button.fire( 'execute' );
+
+				const spy = sinon.spy( balloon, 'updatePosition' );
+
+				editingView.fire( 'render' );
+				sinon.assert.calledOnce( spy );
 			} );
 			} );
 		} );
 		} );
 
 
-		describe( 'close listeners', () => {
-			let hidePanelSpy;
+		describe( 'integration with the editor focus', () => {
+			it( 'should hide the toolbar when the editor loses focus and the image is selected', () => {
+				editor.ui.focusTracker.isFocused = true;
+
+				setData( doc, '[<image src=""></image>]' );
+				button.fire( 'execute' );
 
 
-			beforeEach( () => {
-				hidePanelSpy = sinon.spy( balloonPanel, 'detach' );
+				const spy = sinon.spy( balloon, 'remove' );
+
+				editor.ui.focusTracker.isFocused = false;
+				sinon.assert.calledWithExactly( spy, form );
 			} );
 			} );
+		} );
 
 
+		describe( 'close listeners', () => {
 			describe( 'keyboard', () => {
 			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 );
+				it( 'should close upon Esc key press and focus the editing view', () => {
+					const hideSpy = sinon.spy( plugin, '_hideForm' );
+					const focusSpy = sinon.spy( editor.editing.view, 'focus' );
+
+					setData( doc, '[<image src=""></image>]' );
+					button.fire( 'execute' );
+
+					const keyEvtData = {
+						keyCode: keyCodes.esc,
+						preventDefault: sinon.spy(),
+						stopPropagation: sinon.spy()
+					};
+
+					form.keystrokes.press( keyEvtData );
+					sinon.assert.calledOnce( hideSpy );
+					sinon.assert.calledOnce( keyEvtData.preventDefault );
+					sinon.assert.calledOnce( focusSpy );
 				} );
 				} );
 			} );
 			} );
 
 
 			describe( 'mouse', () => {
 			describe( 'mouse', () => {
 				it( 'should close and not focus editable on click outside the panel', () => {
 				it( 'should close and not focus editable on click outside the panel', () => {
-					balloonPanel.isVisible = true;
-					global.document.body.dispatchEvent( new Event( 'mouseup', { bubbles: true } ) );
+					const hideSpy = sinon.spy( plugin, '_hideForm' );
+					const focusSpy = sinon.spy( editor.editing.view, 'focus' );
 
 
-					sinon.assert.called( hidePanelSpy );
+					setData( doc, '[<image src=""></image>]' );
+					button.fire( 'execute' );
+
+					global.document.body.dispatchEvent( new Event( 'mouseup', { bubbles: true } ) );
+					sinon.assert.called( hideSpy );
+					sinon.assert.notCalled( focusSpy );
 				} );
 				} );
 
 
 				it( 'should not close on click inside the panel', () => {
 				it( 'should not close on click inside the panel', () => {
-					balloonPanel.isVisible = true;
-					balloonPanel.element.dispatchEvent( new Event( 'mouseup', { bubbles: true } ) );
+					const spy = sinon.spy( plugin, '_hideForm' );
 
 
-					sinon.assert.notCalled( hidePanelSpy );
-				} );
-			} );
-		} );
-	} );
+					setData( doc, '[<image src=""></image>]' );
+					button.fire( 'execute' );
 
 
-	describe( 'working with ImageToolbar', () => {
-		let editor, button, imageToolbarPlugin, plugin;
-
-		beforeEach( () => {
-			const editorElement = global.document.createElement( 'div' );
-			global.document.body.appendChild( editorElement );
-
-			return ClassicTestEditor.create( editorElement, {
-				plugins: [ ImageTextAlternative, Image, ImageToolbar ],
-				image: {
-					toolbar: [ 'imageTextAlternative' ]
-				}
-			} )
-			.then( newEditor => {
-				editor = newEditor;
-				editor.editing.view.attachDomRoot( editorElement );
-				button = newEditor.ui.componentFactory.create( 'imageTextAlternative' );
-				imageToolbarPlugin = newEditor.plugins.get( ImageToolbar );
-				plugin = editor.plugins.get( ImageTextAlternative );
+					form.element.dispatchEvent( new Event( 'mouseup', { bubbles: true } ) );
+					sinon.assert.notCalled( spy );
+				} );
 			} );
 			} );
 		} );
 		} );
-
-		afterEach( () => editor.destroy() );
-
-		it( 'should hide ImageToolbar when visible', () => {
-			setData( editor.document, '[<image src="" alt="foo bar"></image>]' );
-			const spy = sinon.spy( imageToolbarPlugin, 'hide' );
-			button.fire( 'execute' );
-
-			sinon.assert.calledOnce( spy );
-		} );
-
-		it( 'ImageToolbar should not show when text alternative panel is visible', () => {
-			setData( editor.document, '[<image src="" alt="foo bar"></image>]' );
-			button.fire( 'execute' );
-			const spy = sinon.spy( imageToolbarPlugin, 'show' );
-			editor.editing.view.render();
-
-			sinon.assert.notCalled( spy );
-		} );
-
-		it( 'ImageToolbar should show when text alternative panel is not visible', () => {
-			setData( editor.document, '[<image src="" alt="foo bar"></image>]' );
-			button.fire( 'execute' );
-			const spy = sinon.spy( imageToolbarPlugin, 'show' );
-			plugin.balloonPanel.isVisible = false;
-
-			sinon.assert.called( spy );
-		} );
 	} );
 	} );
 } );
 } );

+ 17 - 0
packages/ckeditor5-image/tests/imagetextalternative/ui/textalternativeformview.js

@@ -7,6 +7,7 @@
 
 
 import TextAlternativeFormView from '../../../src/imagetextalternative/ui/textalternativeformview';
 import TextAlternativeFormView from '../../../src/imagetextalternative/ui/textalternativeformview';
 import View from '@ckeditor/ckeditor5-ui/src/view';
 import View from '@ckeditor/ckeditor5-ui/src/view';
+import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler';
 
 
 describe( 'TextAlternativeFormView', () => {
 describe( 'TextAlternativeFormView', () => {
 	let view;
 	let view;
@@ -22,6 +23,10 @@ describe( 'TextAlternativeFormView', () => {
 			expect( view.element.classList.contains( 'cke-text-alternative-form' ) ).to.be.true;
 			expect( view.element.classList.contains( 'cke-text-alternative-form' ) ).to.be.true;
 		} );
 		} );
 
 
+		it( 'should create #keystrokes instance', () => {
+			expect( view.keystrokes ).to.be.instanceOf( KeystrokeHandler );
+		} );
+
 		it( 'should create child views', () => {
 		it( 'should create child views', () => {
 			expect( view.labeledInput ).to.be.instanceOf( View );
 			expect( view.labeledInput ).to.be.instanceOf( View );
 			expect( view.saveButtonView ).to.be.instanceOf( View );
 			expect( view.saveButtonView ).to.be.instanceOf( View );
@@ -37,6 +42,18 @@ describe( 'TextAlternativeFormView', () => {
 		} );
 		} );
 	} );
 	} );
 
 
+	describe( 'init()', () => {
+		it( 'starts listening for #keystrokes coming from #element', () => {
+			view = new TextAlternativeFormView( { t: () => {} } );
+
+			const spy = sinon.spy( view.keystrokes, 'listenTo' );
+
+			view.init();
+			sinon.assert.calledOnce( spy );
+			sinon.assert.calledWithExactly( spy, view.element );
+		} );
+	} );
+
 	describe( 'DOM bindings', () => {
 	describe( 'DOM bindings', () => {
 		describe( 'submit event', () => {
 		describe( 'submit event', () => {
 			it( 'should trigger submit event', () => {
 			it( 'should trigger submit event', () => {

+ 113 - 40
packages/ckeditor5-image/tests/imagetoolbar.js

@@ -3,38 +3,45 @@
  * For licensing, see LICENSE.md.
  * For licensing, see LICENSE.md.
  */
  */
 
 
+/* global document */
+
 import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
 import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
 import ImageToolbar from '../src/imagetoolbar';
 import ImageToolbar from '../src/imagetoolbar';
 import Image from '../src/image';
 import Image from '../src/image';
 import global from '@ckeditor/ckeditor5-utils/src/dom/global';
 import global from '@ckeditor/ckeditor5-utils/src/dom/global';
-import ImageBalloonPanel from '../src/image/ui/imageballoonpanelview';
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
 import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import Range from '@ckeditor/ckeditor5-engine/src/model/range';
+import View from '@ckeditor/ckeditor5-ui/src/view';
 import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 
 
 describe( 'ImageToolbar', () => {
 describe( 'ImageToolbar', () => {
-	let editor, editingView, doc, panel, plugin;
+	let editor, doc, editingView, plugin, toolbar, balloon, editorElement;
 
 
 	beforeEach( () => {
 	beforeEach( () => {
-		const editorElement = global.document.createElement( 'div' );
+		editorElement = global.document.createElement( 'div' );
 		global.document.body.appendChild( editorElement );
 		global.document.body.appendChild( editorElement );
 
 
 		return ClassicEditor.create( editorElement, {
 		return ClassicEditor.create( editorElement, {
-			plugins: [ Image, ImageToolbar, FakeButton ],
+			plugins: [ Paragraph, Image, ImageToolbar, FakeButton ],
 			image: {
 			image: {
 				toolbar: [ 'fake_button' ]
 				toolbar: [ 'fake_button' ]
 			}
 			}
 		} )
 		} )
 		.then( newEditor => {
 		.then( newEditor => {
 			editor = newEditor;
 			editor = newEditor;
-			editingView = editor.editing.view;
 			doc = editor.document;
 			doc = editor.document;
 			plugin = editor.plugins.get( ImageToolbar );
 			plugin = editor.plugins.get( ImageToolbar );
-			panel = plugin._panel;
+			toolbar = plugin._toolbar;
+			editingView = editor.editing.view;
+			balloon = editor.plugins.get( 'ContextualBalloon' );
 		} );
 		} );
 	} );
 	} );
 
 
 	afterEach( () => {
 	afterEach( () => {
+		editorElement.remove();
+
 		return editor.destroy();
 		return editor.destroy();
 	} );
 	} );
 
 
@@ -49,57 +56,123 @@ describe( 'ImageToolbar', () => {
 		return ClassicEditor.create( editorElement, {
 		return ClassicEditor.create( editorElement, {
 			plugins: [ ImageToolbar ],
 			plugins: [ ImageToolbar ],
 		} )
 		} )
-			.then( newEditor => {
-				expect( newEditor.plugins.get( ImageToolbar )._panel ).to.be.undefined;
+			.then( editor => {
+				expect( editor.plugins.get( ImageToolbar )._toolbar ).to.be.undefined;
 
 
-				newEditor.destroy();
+				editorElement.remove();
+				editor.destroy();
 			} );
 			} );
 	} );
 	} );
 
 
-	it( 'should set css classes', () => {
-		expect( panel.element.classList.contains( 'ck-toolbar-container' ) ).to.be.true;
-		expect( panel.element.classList.contains( 'ck-editor-toolbar-container' ) ).to.be.true;
-		expect( panel.content.get( 0 ).element.classList.contains( 'ck-editor-toolbar' ) ).to.be.true;
-	} );
+	describe( 'toolbar', () => {
+		it( 'should use the config.image.toolbar to create items', () => {
+			expect( toolbar.items ).to.have.length( 1 );
+			expect( toolbar.items.get( 0 ).label ).to.equal( 'fake button' );
+		} );
 
 
-	it( 'should add ImageBalloonPanel to view body', () => {
-		expect( panel ).to.be.instanceOf( ImageBalloonPanel );
-	} );
+		it( 'should set proper CSS classes', () => {
+			const spy = sinon.spy( balloon, 'add' );
 
 
-	it( 'should show the panel when editor gains focus and image is selected', () => {
-		setData( doc, '[<image src=""></image>]' );
+			editor.ui.focusTracker.isFocused = true;
 
 
-		editor.ui.focusTracker.isFocused = false;
-		const spy = sinon.spy( plugin, 'show' );
-		editor.ui.focusTracker.isFocused = true;
+			setData( doc, '[<image src=""></image>]' );
 
 
-		sinon.assert.calledOnce( spy );
+			expect( toolbar.element.classList.contains( 'ck-editor-toolbar' ) ).to.be.true;
+
+			sinon.assert.calledWithMatch( spy, {
+				view: toolbar,
+				balloonClassName: 'ck-toolbar-container ck-editor-toolbar-container'
+			} );
+		} );
 	} );
 	} );
 
 
-	it( 'should not show the panel automatically when it is disabled', () => {
-		plugin.isEnabled = false;
-		setData( doc, '[<image src=""></image>]' );
-		editor.ui.focusTracker.isFocused = true;
-		const spy = sinon.spy( plugin, 'show' );
+	describe( 'integration with the editor focus', () => {
+		it( 'should show the toolbar when the editor gains focus and the image is selected', () => {
+			editor.ui.focusTracker.isFocused = true;
 
 
-		editingView.render();
+			setData( doc, '[<image src=""></image>]' );
 
 
-		sinon.assert.notCalled( spy );
-	} );
+			editor.ui.focusTracker.isFocused = false;
+			expect( balloon.visibleView ).to.be.null;
+
+			editor.ui.focusTracker.isFocused = true;
+			expect( balloon.visibleView ).to.equal( toolbar );
+		} );
+
+		it( 'should hide the toolbar when the editor loses focus and the image is selected', () => {
+			editor.ui.focusTracker.isFocused = false;
 
 
-	it( 'should not show the panel when editor looses focus', () => {
-		editor.ui.focusTracker.isFocused = true;
-		const spy = sinon.spy( plugin, 'show' );
-		editor.ui.focusTracker.isFocused = false;
+			setData( doc, '[<image src=""></image>]' );
 
 
-		sinon.assert.notCalled( spy );
+			editor.ui.focusTracker.isFocused = true;
+			expect( balloon.visibleView ).to.equal( toolbar );
+
+			editor.ui.focusTracker.isFocused = false;
+			expect( balloon.visibleView ).to.be.null;
+		} );
 	} );
 	} );
 
 
-	it( 'should detach panel with hide() method', () => {
-		const spy = sinon.spy( panel, 'detach' );
-		plugin.hide();
+	describe( 'integration with the editor selection (#render event)', () => {
+		beforeEach( () => {
+			editor.ui.focusTracker.isFocused = true;
+		} );
+
+		it( 'should show the toolbar on render when the image is selected', () => {
+			setData( doc, '<paragraph>[foo]</paragraph><image src=""></image>' );
 
 
-		sinon.assert.calledOnce( spy );
+			expect( balloon.visibleView ).to.be.null;
+
+			editingView.fire( 'render' );
+			expect( balloon.visibleView ).to.be.null;
+
+			doc.enqueueChanges( () => {
+				// Select the [<image></image>]
+				doc.selection.setRanges( [
+					Range.createOn( doc.getRoot().getChild( 1 ) )
+				] );
+			} );
+
+			expect( balloon.visibleView ).to.equal( toolbar );
+
+			// Make sure successive render does not throw, e.g. attempting
+			// to insert the toolbar twice.
+			editingView.fire( 'render' );
+			expect( balloon.visibleView ).to.equal( toolbar );
+		} );
+
+		it( 'should not engage when the toolbar is in the balloon yet invisible', () => {
+			setData( doc, '[<image src=""></image>]' );
+			expect( balloon.visibleView ).to.equal( toolbar );
+
+			const lastView = new View();
+			lastView.element = document.createElement( 'div' );
+
+			balloon.add( { view: lastView } );
+			expect( balloon.visibleView ).to.equal( lastView );
+
+			editingView.fire( 'render' );
+			expect( balloon.visibleView ).to.equal( lastView );
+		} );
+
+		it( 'should hide the toolbar on render if the image is de–selected', () => {
+			setData( doc, '<paragraph>foo</paragraph>[<image src=""></image>]' );
+
+			expect( balloon.visibleView ).to.equal( toolbar );
+
+			doc.enqueueChanges( () => {
+				// Select the <paragraph>[...]</paragraph>
+				doc.selection.setRanges( [
+					Range.createIn( doc.getRoot().getChild( 0 ) )
+				] );
+			} );
+
+			expect( balloon.visibleView ).to.be.null;
+
+			// Make sure successive render does not throw, e.g. attempting
+			// to remove the toolbar twice.
+			editingView.fire( 'render' );
+			expect( balloon.visibleView ).to.be.null;
+		} );
 	} );
 	} );
 
 
 	// Plugin that adds fake_button to editor's component factory.
 	// Plugin that adds fake_button to editor's component factory.

+ 21 - 0
packages/ckeditor5-image/tests/manual/tickets/106/1.html

@@ -0,0 +1,21 @@
+<h2>External changes (edit)</h2>
+<p><button id="button-insert">Start external changes</button></p>
+
+<div id="editor-insert">
+	<p>EXTERNAL CHANGES WILL START HERE -> This specification defines the 5th of the core language of the the Hypertext Markup Language (HTML).</p>
+	<figure class="image">
+		<img src="../../sample.jpg" alt="CKEditor logo" />
+	</figure>
+</div>
+
+<h2>External changes (delete)</h2>
+<p><button id="button-delete">Start external changes</button></p>
+
+<div id="editor-delete">
+	<p>A first paragraph.</p>
+	<p>THIS PARAGRAPH WILL BE REMOVED <a href="http://ckeditor.com">major revision</a>.</p>
+	<figure class="image">
+		<img src="../../sample.jpg" alt="CKEditor logo" />
+	</figure>
+	<p>A third paragraph.</p>
+</div>

+ 116 - 0
packages/ckeditor5-image/tests/manual/tickets/106/1.js

@@ -0,0 +1,116 @@
+/**
+ * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals console:false, document, setTimeout */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import ArticlePresets from '@ckeditor/ckeditor5-presets/src/article';
+
+import Element from '@ckeditor/ckeditor5-engine/src/model/element';
+import Text from '@ckeditor/ckeditor5-engine/src/model/text';
+import Position from '@ckeditor/ckeditor5-engine/src/model/position';
+import Range from '@ckeditor/ckeditor5-engine/src/model/range';
+
+const config = {
+	plugins: [ ArticlePresets ],
+	toolbar: [ 'undo', 'redo', 'link' ],
+	image: {
+		toolbar: [ 'imageTextAlternative' ]
+	}
+};
+
+// Editor for the external insert.
+ClassicEditor.create( document.querySelector( '#editor-insert' ), config )
+	.then( editor => {
+		const element = document.querySelector( '#button-insert' );
+
+		element.addEventListener( 'click', () => {
+			element.disabled = true;
+			startExternalInsert( editor );
+		} );
+	} )
+	.catch( err => console.error( err.stack ) );
+
+// Editor for the external delete.
+ClassicEditor.create( document.querySelector( '#editor-delete' ), config )
+	.then( editor => {
+		const element = document.querySelector( '#button-delete' );
+
+		element.addEventListener( 'click', () => {
+			element.disabled = true;
+			startExternalDelete( editor );
+		} );
+	} )
+	.catch( err => console.error( err.stack ) );
+
+function wait( delay ) {
+	return new Promise( resolve => {
+		setTimeout( () => resolve(), delay );
+	} );
+}
+
+function startExternalInsert( editor ) {
+	const document = editor.document;
+	const bath = document.batch( 'transparent' );
+
+	function type( path, text ) {
+		return new Promise( resolve => {
+			let position = new Position( document.getRoot(), path );
+			let index = 0;
+
+			function typing() {
+				wait( 40 ).then( () => {
+					document.enqueueChanges( () => {
+						bath.insert( position, new Text( text[ index ] ) );
+						position = position.getShiftedBy( 1 );
+
+						const nextLetter = text[ ++index ];
+
+						if ( nextLetter ) {
+							typing( nextLetter );
+						} else {
+							index = 0;
+							resolve();
+						}
+					} );
+				} );
+			}
+
+			typing();
+		} );
+	}
+
+	function insertNewLine( path ) {
+		return wait( 200 ).then( () => {
+			document.enqueueChanges( () => {
+				bath.insert( new Position( document.getRoot(), path ), new Element( 'paragraph' ) );
+			} );
+
+			return Promise.resolve();
+		} );
+	}
+
+	wait( 3000 )
+		.then( () => type( [ 0, 36 ], 'This specification defines the 5th major revision of the core language of the World Wide Web. ' ) )
+		.then( () => insertNewLine( [ 0 ] ) )
+		.then( () => type( [ 0, 0 ], 'a' ) )
+		.then( () => insertNewLine( [ 1 ] ) )
+		.then( () => type( [ 1, 0 ], 'b' ) )
+		.then( () => insertNewLine( [ 2 ] ) )
+		.then( () => type( [ 2, 0 ], 'c' ) )
+		.then( () => insertNewLine( [ 0 ] ) )
+		.then( () => type( [ 0, 0 ], 'DONE :)' ) );
+}
+
+function startExternalDelete( editor ) {
+	const document = editor.document;
+	const bath = document.batch( 'transparent' );
+
+	wait( 3000 ).then( () => {
+		document.enqueueChanges( () => {
+			bath.remove( Range.createFromPositionAndShift( new Position( document.getRoot(), [ 1 ] ), 1 ) );
+		} );
+	} );
+}

+ 13 - 0
packages/ckeditor5-image/tests/manual/tickets/106/1.md

@@ -0,0 +1,13 @@
+# Issue [#106](https://github.com/ckeditor/ckeditor5-image/issues/106) manual test.
+
+## Position of the text alternative balloon should be updated on external changes.
+
+### Edit:
+
+1. Click **Start external changes** then quickly select the image in the content, then from the toolbar open the **text alternative** editing balloon.
+2. Observe if the balloon remains attached to the image as the new content shows up.
+
+### Delete:
+
+1. Click **Start external changes** then quickly select the image in the content, then from the toolbar open the **text alternative** editing balloon.
+2. Observe if the balloon remains attached to the image as the content is deleted.