Bladeren bron

Merge pull request #167 from ckeditor/t/ckeditor5/488

Other: Align feature class naming to a new scheme.
Szymon Cofalik 7 jaren geleden
bovenliggende
commit
0d9b3c7829

+ 5 - 511
packages/ckeditor5-link/src/link.js

@@ -8,28 +8,14 @@
  */
 
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
-import ClickObserver from '@ckeditor/ckeditor5-engine/src/view/observer/clickobserver';
-import Range from '@ckeditor/ckeditor5-engine/src/view/range';
-import LinkEngine from './linkengine';
-import { isLinkElement } from './utils';
-import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon';
-
-import clickOutsideHandler from '@ckeditor/ckeditor5-ui/src/bindings/clickoutsidehandler';
-import bindTwoStepCaretToAttribute from '@ckeditor/ckeditor5-engine/src/utils/bindtwostepcarettoattribute';
-
-import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
-import LinkFormView from './ui/linkformview';
-import LinkActionsView from './ui/linkactionsview';
-
-import linkIcon from '../theme/icons/link.svg';
-
-const linkKeystroke = 'Ctrl+K';
+import LinkEditing from './linkediting';
+import LinkUI from './linkui';
 
 /**
  * The link plugin. It introduces the Link and Unlink buttons and the <kbd>Ctrl+K</kbd> keystroke.
  *
- * It uses the {@link module:link/linkengine~LinkEngine link engine plugin} and the
- * {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon contextual balloon plugin}.
+ * It loads the {@link module:link/linkediting~LinkEditing link editing feature}
+ * and {@link module:link/linkui~LinkUI link UI feature}.
  *
  * @extends module:core/plugin~Plugin
  */
@@ -38,7 +24,7 @@ export default class Link extends Plugin {
 	 * @inheritDoc
 	 */
 	static get requires() {
-		return [ LinkEngine, ContextualBalloon ];
+		return [ LinkEditing, LinkUI ];
 	}
 
 	/**
@@ -47,496 +33,4 @@ export default class Link extends Plugin {
 	static get pluginName() {
 		return 'Link';
 	}
-
-	/**
-	 * @inheritDoc
-	 */
-	init() {
-		const editor = this.editor;
-
-		editor.editing.view.addObserver( ClickObserver );
-
-		/**
-		 * The actions view displayed inside of the balloon.
-		 *
-		 * @member {module:link/ui/linkactionsview~LinkActionsView}
-		 */
-		this.actionsView = this._createActionsView();
-
-		/**
-		 * The form view displayed inside the balloon.
-		 *
-		 * @member {module:link/ui/linkformview~LinkFormView}
-		 */
-		this.formView = this._createFormView();
-
-		/**
-		 * The contextual balloon plugin instance.
-		 *
-		 * @private
-		 * @member {module:ui/panel/balloon/contextualballoon~ContextualBalloon}
-		 */
-		this._balloon = editor.plugins.get( ContextualBalloon );
-
-		// Create toolbar buttons.
-		this._createToolbarLinkButton();
-
-		// Attach lifecycle actions to the the balloon.
-		this._enableUserBalloonInteractions();
-
-		// Enable two-step caret movement for `linkHref` attribute.
-		bindTwoStepCaretToAttribute( editor.editing.view, editor.model, this, 'linkHref' );
-	}
-
-	/**
-	 * Creates the {@link module:link/ui/linkactionsview~LinkActionsView} instance.
-	 *
-	 * @private
-	 * @returns {module:link/ui/linkactionsview~LinkActionsView} The link actions view instance.
-	 */
-	_createActionsView() {
-		const editor = this.editor;
-		const actionsView = new LinkActionsView( editor.locale );
-		const linkCommand = editor.commands.get( 'link' );
-		const unlinkCommand = editor.commands.get( 'unlink' );
-
-		actionsView.bind( 'href' ).to( linkCommand, 'value' );
-		actionsView.editButtonView.bind( 'isEnabled' ).to( linkCommand );
-		actionsView.unlinkButtonView.bind( 'isEnabled' ).to( unlinkCommand );
-
-		// Execute unlink command after clicking on the "Edit" button.
-		this.listenTo( actionsView, 'edit', () => {
-			this._addFormView();
-		} );
-
-		// Execute unlink command after clicking on the "Unlink" button.
-		this.listenTo( actionsView, 'unlink', () => {
-			editor.execute( 'unlink' );
-			this._hideUI();
-		} );
-
-		// Close the panel on esc key press when the **actions have focus**.
-		actionsView.keystrokes.set( 'Esc', ( data, cancel ) => {
-			this._hideUI();
-			cancel();
-		} );
-
-		return actionsView;
-	}
-
-	/**
-	 * Creates the {@link module:link/ui/linkformview~LinkFormView} instance.
-	 *
-	 * @private
-	 * @returns {module:link/ui/linkformview~LinkFormView} The link form instance.
-	 */
-	_createFormView() {
-		const editor = this.editor;
-		const formView = new LinkFormView( editor.locale );
-		const linkCommand = editor.commands.get( 'link' );
-
-		formView.urlInputView.bind( 'value' ).to( linkCommand, 'value' );
-
-		// Form elements should be read-only when corresponding commands are disabled.
-		formView.urlInputView.bind( 'isReadOnly' ).to( linkCommand, 'isEnabled', value => !value );
-		formView.saveButtonView.bind( 'isEnabled' ).to( linkCommand );
-
-		// Execute link command after clicking the "Save" button.
-		this.listenTo( formView, 'submit', () => {
-			editor.execute( 'link', formView.urlInputView.inputView.element.value );
-			this._removeFormView();
-		} );
-
-		// Hide the panel after clicking the "Cancel" button.
-		this.listenTo( formView, 'cancel', () => {
-			this._removeFormView();
-		} );
-
-		// Close the panel on esc key press when the **form has focus**.
-		formView.keystrokes.set( 'Esc', ( data, cancel ) => {
-			this._removeFormView();
-			cancel();
-		} );
-
-		return formView;
-	}
-
-	/**
-	 * Creates a toolbar Link button. Clicking this button will show
-	 * a {@link #_balloon} attached to the selection.
-	 *
-	 * @private
-	 */
-	_createToolbarLinkButton() {
-		const editor = this.editor;
-		const linkCommand = editor.commands.get( 'link' );
-		const t = editor.t;
-
-		// Handle the `Ctrl+K` keystroke and show the panel.
-		editor.keystrokes.set( linkKeystroke, ( keyEvtData, cancel ) => {
-			// Prevent focusing the search bar in FF and opening new tab in Edge. #153, #154.
-			cancel();
-
-			if ( linkCommand.isEnabled ) {
-				this._showUI();
-			}
-		} );
-
-		editor.ui.componentFactory.add( 'link', locale => {
-			const button = new ButtonView( locale );
-
-			button.isEnabled = true;
-			button.label = t( 'Link' );
-			button.icon = linkIcon;
-			button.keystroke = linkKeystroke;
-			button.tooltip = true;
-
-			// Bind button to the command.
-			button.bind( 'isEnabled' ).to( linkCommand, 'isEnabled' );
-
-			// Show the panel on button click.
-			this.listenTo( button, 'execute', () => this._showUI() );
-
-			return button;
-		} );
-	}
-
-	/**
-	 * Attaches actions that control whether the balloon panel containing the
-	 * {@link #formView} is visible or not.
-	 *
-	 * @private
-	 */
-	_enableUserBalloonInteractions() {
-		const viewDocument = this.editor.editing.view.document;
-
-		// Handle click on view document and show panel when selection is placed inside the link element.
-		// Keep panel open until selection will be inside the same link element.
-		this.listenTo( viewDocument, 'click', () => {
-			const parentLink = this._getSelectedLinkElement();
-
-			if ( parentLink ) {
-				// Then show panel but keep focus inside editor editable.
-				this._showUI();
-			}
-		} );
-
-		// Focus the form if the balloon is visible and the Tab key has been pressed.
-		this.editor.keystrokes.set( 'Tab', ( data, cancel ) => {
-			if ( this._areActionsVisible && !this.actionsView.focusTracker.isFocused ) {
-				this.actionsView.focus();
-				cancel();
-			}
-		}, {
-			// Use the high priority because the link UI navigation is more important
-			// than other feature's actions, e.g. list indentation.
-			// https://github.com/ckeditor/ckeditor5-link/issues/146
-			priority: 'high'
-		} );
-
-		// Close the panel on the Esc key press when the editable has focus and the balloon is visible.
-		this.editor.keystrokes.set( 'Esc', ( data, cancel ) => {
-			if ( this._isUIVisible ) {
-				this._hideUI();
-				cancel();
-			}
-		} );
-
-		// Close on click outside of balloon panel element.
-		clickOutsideHandler( {
-			emitter: this.formView,
-			activator: () => this._isUIVisible,
-			contextElements: [ this._balloon.view.element ],
-			callback: () => this._hideUI()
-		} );
-	}
-
-	/**
-	 * Adds the {@link #actionsView} to the {@link #_balloon}.
-	 *
-	 * @protected
-	 */
-	_addActionsView() {
-		this._balloon.add( {
-			view: this.actionsView,
-			position: this._getBalloonPositionData()
-		} );
-	}
-
-	/**
-	 * Adds the {@link #formView} to the {@link #_balloon}.
-	 *
-	 * @protected
-	 */
-	_addFormView() {
-		const editor = this.editor;
-		const linkCommand = editor.commands.get( 'link' );
-
-		this._balloon.add( {
-			view: this.formView,
-			position: this._getBalloonPositionData()
-		} );
-
-		this.formView.urlInputView.select();
-
-		// Make sure that each time the panel shows up, the URL field remains in sync with the value of
-		// the command. If the user typed in the input, then canceled the balloon (`urlInputView#value` stays
-		// unaltered) and re-opened it without changing the value of the link command (e.g. because they
-		// clicked the same link), they would see the old value instead of the actual value of the command.
-		// https://github.com/ckeditor/ckeditor5-link/issues/78
-		// https://github.com/ckeditor/ckeditor5-link/issues/123
-		this.formView.urlInputView.inputView.element.value = linkCommand.value || '';
-	}
-
-	/**
-	 * Removes the {@link #formView} from the {@link #_balloon}.
-	 *
-	 * @protected
-	 */
-	_removeFormView() {
-		if ( this._isFormInPanel ) {
-			this._balloon.remove( this.formView );
-
-			// Because the form has an input which has focus, the focus must be brought back
-			// to the editor. Otherwise, it would be lost.
-			this.editor.editing.view.focus();
-		}
-	}
-
-	/**
-	 * Shows the right kind of the UI for current state of the command. It's either
-	 * {@link #formView} or {@link #actionsView}.
-	 *
-	 * @private
-	 */
-	_showUI() {
-		const editor = this.editor;
-		const linkCommand = editor.commands.get( 'link' );
-
-		if ( !linkCommand.isEnabled || this._isUIInPanel ) {
-			return;
-		}
-
-		// When there's no link under the selection, go straight to the editing UI.
-		if ( !this._getSelectedLinkElement() ) {
-			this._addActionsView();
-			this._addFormView();
-		}
-		// Otherwise display just the actions UI.
-		else {
-			this._addActionsView();
-		}
-
-		// Begin responding to view#render once the UI is added.
-		this._startUpdatingUIOnViewRender();
-	}
-
-	/**
-	 * Removes the {@link #formView} from the {@link #_balloon}.
-	 *
-	 * See {@link #_addFormView}, {@link #_addActionsView}.
-	 *
-	 * @protected
-	 */
-	_hideUI() {
-		if ( !this._isUIInPanel ) {
-			return;
-		}
-
-		const editingView = this.editor.editing.view;
-
-		this.stopListening( editingView, 'render' );
-
-		// Remove form first because it's on top of the stack.
-		this._removeFormView();
-
-		// Then remove the actions view because it's beneath the form.
-		this._balloon.remove( this.actionsView );
-
-		// Make sure the focus always gets back to the editable.
-		editingView.focus();
-	}
-
-	/**
-	 * Makes the UI react to the {@link module:engine/view/view~View#event:render} in the view to
-	 * reposition itself as the document changes.
-	 *
-	 * See: {@link #_hideUI} to learn when the UI stops reacting to the `render` event.
-	 *
-	 * @protected
-	 */
-	_startUpdatingUIOnViewRender() {
-		const editor = this.editor;
-		const editing = editor.editing;
-		const editingView = editing.view;
-
-		let prevSelectedLink = this._getSelectedLinkElement();
-		let prevSelectionParent = getSelectionParent();
-
-		this.listenTo( editingView, 'render', () => {
-			const selectedLink = this._getSelectedLinkElement();
-			const selectionParent = getSelectionParent();
-
-			// Hide the panel if:
-			//
-			// * the selection went out of the EXISTING link element. E.g. user moved the caret out
-			//   of the link,
-			// * the selection went to a different parent when creating a NEW link. E.g. someone
-			//   else modified the document.
-			// * the selection has expanded (e.g. displaying link actions then pressing SHIFT+Right arrow).
-			//
-			// Note: #_getSelectedLinkElement will return a link for a non-collapsed selection only
-			// when fully selected.
-			if ( ( prevSelectedLink && !selectedLink ) ||
-				( !prevSelectedLink && selectionParent !== prevSelectionParent ) ) {
-				this._hideUI();
-			}
-			// Update the position of the panel when:
-			//  * the selection remains in the original link element,
-			//  * there was no link element in the first place, i.e. creating a new link
-			else {
-				// If still in a link element, simply update the position of the balloon.
-				// If there was no link (e.g. inserting one), the balloon must be moved
-				// to the new position in the editing view (a new native DOM range).
-				this._balloon.updatePosition( this._getBalloonPositionData() );
-			}
-
-			prevSelectedLink = selectedLink;
-			prevSelectionParent = selectionParent;
-		} );
-
-		function getSelectionParent() {
-			return editingView.document.selection.focus.getAncestors()
-				.reverse()
-				.find( node => node.is( 'element' ) );
-		}
-	}
-
-	/**
-	 * Returns true when {@link #formView} is in the {@link #_balloon}.
-	 *
-	 * @readonly
-	 * @protected
-	 * @type {Boolean}
-	 */
-	get _isFormInPanel() {
-		return this._balloon.hasView( this.formView );
-	}
-
-	/**
-	 * Returns true when {@link #actionsView} is in the {@link #_balloon}.
-	 *
-	 * @readonly
-	 * @protected
-	 * @type {Boolean}
-	 */
-	get _areActionsInPanel() {
-		return this._balloon.hasView( this.actionsView );
-	}
-
-	/**
-	 * Returns true when {@link #actionsView} is in the {@link #_balloon} and it is
-	 * currently visible.
-	 *
-	 * @readonly
-	 * @protected
-	 * @type {Boolean}
-	 */
-	get _areActionsVisible() {
-		return this._balloon.visibleView === this.actionsView;
-	}
-
-	/**
-	 * Returns true when {@link #actionsView} or {@link #formView} is in the {@link #_balloon}.
-	 *
-	 * @readonly
-	 * @protected
-	 * @type {Boolean}
-	 */
-	get _isUIInPanel() {
-		return this._isFormInPanel || this._areActionsInPanel;
-	}
-
-	/**
-	 * Returns true when {@link #actionsView} or {@link #formView} is in the {@link #_balloon} and it is
-	 * currently visible.
-	 *
-	 * @readonly
-	 * @protected
-	 * @type {Boolean}
-	 */
-	get _isUIVisible() {
-		const visibleView = this._balloon.visibleView;
-
-		return visibleView == this.formView || this._areActionsVisible;
-	}
-
-	/**
-	 * Returns positioning options for the {@link #_balloon}. They control the way the balloon is attached
-	 * to the target element or selection.
-	 *
-	 * If the selection is collapsed and inside a link element, the panel will be attached to the
-	 * entire link element. Otherwise, it will be attached to the selection.
-	 *
-	 * @private
-	 * @returns {module:utils/dom/position~Options}
-	 */
-	_getBalloonPositionData() {
-		const view = this.editor.editing.view;
-		const viewDocument = view.document;
-		const targetLink = this._getSelectedLinkElement();
-
-		const target = targetLink ?
-			// When selection is inside link element, then attach panel to this element.
-			view.domConverter.mapViewToDom( targetLink ) :
-			// Otherwise attach panel to the selection.
-			view.domConverter.viewRangeToDom( viewDocument.selection.getFirstRange() );
-
-		return { target };
-	}
-
-	/**
-	 * Returns the link {@link module:engine/view/attributeelement~AttributeElement} under
-	 * the {@link module:engine/view/document~Document editing view's} selection or `null`
-	 * if there is none.
-	 *
-	 * **Note**: For a non–collapsed selection the link element is only returned when **fully**
-	 * selected and the **only** element within the selection boundaries.
-	 *
-	 * @private
-	 * @returns {module:engine/view/attributeelement~AttributeElement|null}
-	 */
-	_getSelectedLinkElement() {
-		const selection = this.editor.editing.view.document.selection;
-
-		if ( selection.isCollapsed ) {
-			return findLinkElementAncestor( selection.getFirstPosition() );
-		} else {
-			// The range for fully selected link is usually anchored in adjacent text nodes.
-			// Trim it to get closer to the actual link element.
-			const range = selection.getFirstRange().getTrimmed();
-			const startLink = findLinkElementAncestor( range.start );
-			const endLink = findLinkElementAncestor( range.end );
-
-			if ( !startLink || startLink != endLink ) {
-				return null;
-			}
-
-			// Check if the link element is fully selected.
-			if ( Range.createIn( startLink ).getTrimmed().isEqual( range ) ) {
-				return startLink;
-			} else {
-				return null;
-			}
-		}
-	}
-}
-
-// Returns a link element if there's one among the ancestors of the provided `Position`.
-//
-// @private
-// @param {module:engine/view/position~Position} View position to analyze.
-// @returns {module:engine/view/attributeelement~AttributeElement|null} Link element at the position or null.
-function findLinkElementAncestor( position ) {
-	return position.getAncestors().find( ancestor => isLinkElement( ancestor ) );
 }

+ 6 - 2
packages/ckeditor5-link/src/linkengine.js

@@ -4,7 +4,7 @@
  */
 
 /**
- * @module link/linkengine
+ * @module link/linkediting
  */
 
 import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
@@ -13,6 +13,7 @@ import { upcastElementToAttribute } from '@ckeditor/ckeditor5-engine/src/convers
 import LinkCommand from './linkcommand';
 import UnlinkCommand from './unlinkcommand';
 import { createLinkElement } from './utils';
+import bindTwoStepCaretToAttribute from '@ckeditor/ckeditor5-engine/src/utils/bindtwostepcarettoattribute';
 
 /**
  * The link engine feature.
@@ -21,7 +22,7 @@ import { createLinkElement } from './utils';
  *
  * @extends module:core/plugin~Plugin
  */
-export default class LinkEngine extends Plugin {
+export default class LinkEditing extends Plugin {
 	/**
 	 * @inheritDoc
 	 */
@@ -51,5 +52,8 @@ export default class LinkEngine extends Plugin {
 		// Create linking commands.
 		editor.commands.add( 'link', new LinkCommand( editor ) );
 		editor.commands.add( 'unlink', new UnlinkCommand( editor ) );
+
+		// Enable two-step caret movement for `linkHref` attribute.
+		bindTwoStepCaretToAttribute( editor.editing.view, editor.model, this, 'linkHref' );
 	}
 }

+ 530 - 0
packages/ckeditor5-link/src/linkui.js

@@ -0,0 +1,530 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module link/linkui
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import ClickObserver from '@ckeditor/ckeditor5-engine/src/view/observer/clickobserver';
+import Range from '@ckeditor/ckeditor5-engine/src/view/range';
+import { isLinkElement } from './utils';
+import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon';
+
+import clickOutsideHandler from '@ckeditor/ckeditor5-ui/src/bindings/clickoutsidehandler';
+
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+import LinkFormView from './ui/linkformview';
+import LinkActionsView from './ui/linkactionsview';
+
+import linkIcon from '../theme/icons/link.svg';
+
+const linkKeystroke = 'Ctrl+K';
+
+/**
+ * The link UI plugin. It introduces the Link and Unlink buttons and the <kbd>Ctrl+K</kbd> keystroke.
+ *
+ * It uses the
+ * {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon contextual balloon plugin}.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class LinkUI extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ ContextualBalloon ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+
+		editor.editing.view.addObserver( ClickObserver );
+
+		/**
+		 * The actions view displayed inside of the balloon.
+		 *
+		 * @member {module:link/ui/linkactionsview~LinkActionsView}
+		 */
+		this.actionsView = this._createActionsView();
+
+		/**
+		 * The form view displayed inside the balloon.
+		 *
+		 * @member {module:link/ui/linkformview~LinkFormView}
+		 */
+		this.formView = this._createFormView();
+
+		/**
+		 * The contextual balloon plugin instance.
+		 *
+		 * @private
+		 * @member {module:ui/panel/balloon/contextualballoon~ContextualBalloon}
+		 */
+		this._balloon = editor.plugins.get( ContextualBalloon );
+
+		// Create toolbar buttons.
+		this._createToolbarLinkButton();
+
+		// Attach lifecycle actions to the the balloon.
+		this._enableUserBalloonInteractions();
+	}
+
+	/**
+	 * Creates the {@link module:link/ui/linkactionsview~LinkActionsView} instance.
+	 *
+	 * @private
+	 * @returns {module:link/ui/linkactionsview~LinkActionsView} The link actions view instance.
+	 */
+	_createActionsView() {
+		const editor = this.editor;
+		const actionsView = new LinkActionsView( editor.locale );
+		const linkCommand = editor.commands.get( 'link' );
+		const unlinkCommand = editor.commands.get( 'unlink' );
+
+		actionsView.bind( 'href' ).to( linkCommand, 'value' );
+		actionsView.editButtonView.bind( 'isEnabled' ).to( linkCommand );
+		actionsView.unlinkButtonView.bind( 'isEnabled' ).to( unlinkCommand );
+
+		// Execute unlink command after clicking on the "Edit" button.
+		this.listenTo( actionsView, 'edit', () => {
+			this._addFormView();
+		} );
+
+		// Execute unlink command after clicking on the "Unlink" button.
+		this.listenTo( actionsView, 'unlink', () => {
+			editor.execute( 'unlink' );
+			this._hideUI();
+		} );
+
+		// Close the panel on esc key press when the **actions have focus**.
+		actionsView.keystrokes.set( 'Esc', ( data, cancel ) => {
+			this._hideUI();
+			cancel();
+		} );
+
+		return actionsView;
+	}
+
+	/**
+	 * Creates the {@link module:link/ui/linkformview~LinkFormView} instance.
+	 *
+	 * @private
+	 * @returns {module:link/ui/linkformview~LinkFormView} The link form instance.
+	 */
+	_createFormView() {
+		const editor = this.editor;
+		const formView = new LinkFormView( editor.locale );
+		const linkCommand = editor.commands.get( 'link' );
+
+		formView.urlInputView.bind( 'value' ).to( linkCommand, 'value' );
+
+		// Form elements should be read-only when corresponding commands are disabled.
+		formView.urlInputView.bind( 'isReadOnly' ).to( linkCommand, 'isEnabled', value => !value );
+		formView.saveButtonView.bind( 'isEnabled' ).to( linkCommand );
+
+		// Execute link command after clicking the "Save" button.
+		this.listenTo( formView, 'submit', () => {
+			editor.execute( 'link', formView.urlInputView.inputView.element.value );
+			this._removeFormView();
+		} );
+
+		// Hide the panel after clicking the "Cancel" button.
+		this.listenTo( formView, 'cancel', () => {
+			this._removeFormView();
+		} );
+
+		// Close the panel on esc key press when the **form has focus**.
+		formView.keystrokes.set( 'Esc', ( data, cancel ) => {
+			this._removeFormView();
+			cancel();
+		} );
+
+		return formView;
+	}
+
+	/**
+	 * Creates a toolbar Link button. Clicking this button will show
+	 * a {@link #_balloon} attached to the selection.
+	 *
+	 * @private
+	 */
+	_createToolbarLinkButton() {
+		const editor = this.editor;
+		const linkCommand = editor.commands.get( 'link' );
+		const t = editor.t;
+
+		// Handle the `Ctrl+K` keystroke and show the panel.
+		editor.keystrokes.set( linkKeystroke, ( keyEvtData, cancel ) => {
+			// Prevent focusing the search bar in FF and opening new tab in Edge. #153, #154.
+			cancel();
+
+			if ( linkCommand.isEnabled ) {
+				this._showUI();
+			}
+		} );
+
+		editor.ui.componentFactory.add( 'link', locale => {
+			const button = new ButtonView( locale );
+
+			button.isEnabled = true;
+			button.label = t( 'Link' );
+			button.icon = linkIcon;
+			button.keystroke = linkKeystroke;
+			button.tooltip = true;
+
+			// Bind button to the command.
+			button.bind( 'isEnabled' ).to( linkCommand, 'isEnabled' );
+
+			// Show the panel on button click.
+			this.listenTo( button, 'execute', () => this._showUI() );
+
+			return button;
+		} );
+	}
+
+	/**
+	 * Attaches actions that control whether the balloon panel containing the
+	 * {@link #formView} is visible or not.
+	 *
+	 * @private
+	 */
+	_enableUserBalloonInteractions() {
+		const viewDocument = this.editor.editing.view.document;
+
+		// Handle click on view document and show panel when selection is placed inside the link element.
+		// Keep panel open until selection will be inside the same link element.
+		this.listenTo( viewDocument, 'click', () => {
+			const parentLink = this._getSelectedLinkElement();
+
+			if ( parentLink ) {
+				// Then show panel but keep focus inside editor editable.
+				this._showUI();
+			}
+		} );
+
+		// Focus the form if the balloon is visible and the Tab key has been pressed.
+		this.editor.keystrokes.set( 'Tab', ( data, cancel ) => {
+			if ( this._areActionsVisible && !this.actionsView.focusTracker.isFocused ) {
+				this.actionsView.focus();
+				cancel();
+			}
+		}, {
+			// Use the high priority because the link UI navigation is more important
+			// than other feature's actions, e.g. list indentation.
+			// https://github.com/ckeditor/ckeditor5-link/issues/146
+			priority: 'high'
+		} );
+
+		// Close the panel on the Esc key press when the editable has focus and the balloon is visible.
+		this.editor.keystrokes.set( 'Esc', ( data, cancel ) => {
+			if ( this._isUIVisible ) {
+				this._hideUI();
+				cancel();
+			}
+		} );
+
+		// Close on click outside of balloon panel element.
+		clickOutsideHandler( {
+			emitter: this.formView,
+			activator: () => this._isUIVisible,
+			contextElements: [ this._balloon.view.element ],
+			callback: () => this._hideUI()
+		} );
+	}
+
+	/**
+	 * Adds the {@link #actionsView} to the {@link #_balloon}.
+	 *
+	 * @protected
+	 */
+	_addActionsView() {
+		this._balloon.add( {
+			view: this.actionsView,
+			position: this._getBalloonPositionData()
+		} );
+	}
+
+	/**
+	 * Adds the {@link #formView} to the {@link #_balloon}.
+	 *
+	 * @protected
+	 */
+	_addFormView() {
+		const editor = this.editor;
+		const linkCommand = editor.commands.get( 'link' );
+
+		this._balloon.add( {
+			view: this.formView,
+			position: this._getBalloonPositionData()
+		} );
+
+		this.formView.urlInputView.select();
+
+		// Make sure that each time the panel shows up, the URL field remains in sync with the value of
+		// the command. If the user typed in the input, then canceled the balloon (`urlInputView#value` stays
+		// unaltered) and re-opened it without changing the value of the link command (e.g. because they
+		// clicked the same link), they would see the old value instead of the actual value of the command.
+		// https://github.com/ckeditor/ckeditor5-link/issues/78
+		// https://github.com/ckeditor/ckeditor5-link/issues/123
+		this.formView.urlInputView.inputView.element.value = linkCommand.value || '';
+	}
+
+	/**
+	 * Removes the {@link #formView} from the {@link #_balloon}.
+	 *
+	 * @protected
+	 */
+	_removeFormView() {
+		if ( this._isFormInPanel ) {
+			this._balloon.remove( this.formView );
+
+			// Because the form has an input which has focus, the focus must be brought back
+			// to the editor. Otherwise, it would be lost.
+			this.editor.editing.view.focus();
+		}
+	}
+
+	/**
+	 * Shows the right kind of the UI for current state of the command. It's either
+	 * {@link #formView} or {@link #actionsView}.
+	 *
+	 * @private
+	 */
+	_showUI() {
+		const editor = this.editor;
+		const linkCommand = editor.commands.get( 'link' );
+
+		if ( !linkCommand.isEnabled || this._isUIInPanel ) {
+			return;
+		}
+
+		// When there's no link under the selection, go straight to the editing UI.
+		if ( !this._getSelectedLinkElement() ) {
+			this._addActionsView();
+			this._addFormView();
+		}
+		// Otherwise display just the actions UI.
+		else {
+			this._addActionsView();
+		}
+
+		// Begin responding to view#render once the UI is added.
+		this._startUpdatingUIOnViewRender();
+	}
+
+	/**
+	 * Removes the {@link #formView} from the {@link #_balloon}.
+	 *
+	 * See {@link #_addFormView}, {@link #_addActionsView}.
+	 *
+	 * @protected
+	 */
+	_hideUI() {
+		if ( !this._isUIInPanel ) {
+			return;
+		}
+
+		const editingView = this.editor.editing.view;
+
+		this.stopListening( editingView, 'render' );
+
+		// Remove form first because it's on top of the stack.
+		this._removeFormView();
+
+		// Then remove the actions view because it's beneath the form.
+		this._balloon.remove( this.actionsView );
+
+		// Make sure the focus always gets back to the editable.
+		editingView.focus();
+	}
+
+	/**
+	 * Makes the UI react to the {@link module:engine/view/view~View#event:render} in the view to
+	 * reposition itself as the document changes.
+	 *
+	 * See: {@link #_hideUI} to learn when the UI stops reacting to the `render` event.
+	 *
+	 * @protected
+	 */
+	_startUpdatingUIOnViewRender() {
+		const editor = this.editor;
+		const editing = editor.editing;
+		const editingView = editing.view;
+
+		let prevSelectedLink = this._getSelectedLinkElement();
+		let prevSelectionParent = getSelectionParent();
+
+		this.listenTo( editingView, 'render', () => {
+			const selectedLink = this._getSelectedLinkElement();
+			const selectionParent = getSelectionParent();
+
+			// Hide the panel if:
+			//
+			// * the selection went out of the EXISTING link element. E.g. user moved the caret out
+			//   of the link,
+			// * the selection went to a different parent when creating a NEW link. E.g. someone
+			//   else modified the document.
+			// * the selection has expanded (e.g. displaying link actions then pressing SHIFT+Right arrow).
+			//
+			// Note: #_getSelectedLinkElement will return a link for a non-collapsed selection only
+			// when fully selected.
+			if ( ( prevSelectedLink && !selectedLink ) ||
+				( !prevSelectedLink && selectionParent !== prevSelectionParent ) ) {
+				this._hideUI();
+			}
+			// Update the position of the panel when:
+			//  * the selection remains in the original link element,
+			//  * there was no link element in the first place, i.e. creating a new link
+			else {
+				// If still in a link element, simply update the position of the balloon.
+				// If there was no link (e.g. inserting one), the balloon must be moved
+				// to the new position in the editing view (a new native DOM range).
+				this._balloon.updatePosition( this._getBalloonPositionData() );
+			}
+
+			prevSelectedLink = selectedLink;
+			prevSelectionParent = selectionParent;
+		} );
+
+		function getSelectionParent() {
+			return editingView.document.selection.focus.getAncestors()
+				.reverse()
+				.find( node => node.is( 'element' ) );
+		}
+	}
+
+	/**
+	 * Returns true when {@link #formView} is in the {@link #_balloon}.
+	 *
+	 * @readonly
+	 * @protected
+	 * @type {Boolean}
+	 */
+	get _isFormInPanel() {
+		return this._balloon.hasView( this.formView );
+	}
+
+	/**
+	 * Returns true when {@link #actionsView} is in the {@link #_balloon}.
+	 *
+	 * @readonly
+	 * @protected
+	 * @type {Boolean}
+	 */
+	get _areActionsInPanel() {
+		return this._balloon.hasView( this.actionsView );
+	}
+
+	/**
+	 * Returns true when {@link #actionsView} is in the {@link #_balloon} and it is
+	 * currently visible.
+	 *
+	 * @readonly
+	 * @protected
+	 * @type {Boolean}
+	 */
+	get _areActionsVisible() {
+		return this._balloon.visibleView === this.actionsView;
+	}
+
+	/**
+	 * Returns true when {@link #actionsView} or {@link #formView} is in the {@link #_balloon}.
+	 *
+	 * @readonly
+	 * @protected
+	 * @type {Boolean}
+	 */
+	get _isUIInPanel() {
+		return this._isFormInPanel || this._areActionsInPanel;
+	}
+
+	/**
+	 * Returns true when {@link #actionsView} or {@link #formView} is in the {@link #_balloon} and it is
+	 * currently visible.
+	 *
+	 * @readonly
+	 * @protected
+	 * @type {Boolean}
+	 */
+	get _isUIVisible() {
+		const visibleView = this._balloon.visibleView;
+
+		return visibleView == this.formView || this._areActionsVisible;
+	}
+
+	/**
+	 * Returns positioning options for the {@link #_balloon}. They control the way the balloon is attached
+	 * to the target element or selection.
+	 *
+	 * If the selection is collapsed and inside a link element, the panel will be attached to the
+	 * entire link element. Otherwise, it will be attached to the selection.
+	 *
+	 * @private
+	 * @returns {module:utils/dom/position~Options}
+	 */
+	_getBalloonPositionData() {
+		const view = this.editor.editing.view;
+		const viewDocument = view.document;
+		const targetLink = this._getSelectedLinkElement();
+
+		const target = targetLink ?
+			// When selection is inside link element, then attach panel to this element.
+			view.domConverter.mapViewToDom( targetLink ) :
+			// Otherwise attach panel to the selection.
+			view.domConverter.viewRangeToDom( viewDocument.selection.getFirstRange() );
+
+		return { target };
+	}
+
+	/**
+	 * Returns the link {@link module:engine/view/attributeelement~AttributeElement} under
+	 * the {@link module:engine/view/document~Document editing view's} selection or `null`
+	 * if there is none.
+	 *
+	 * **Note**: For a non–collapsed selection the link element is only returned when **fully**
+	 * selected and the **only** element within the selection boundaries.
+	 *
+	 * @private
+	 * @returns {module:engine/view/attributeelement~AttributeElement|null}
+	 */
+	_getSelectedLinkElement() {
+		const selection = this.editor.editing.view.document.selection;
+
+		if ( selection.isCollapsed ) {
+			return findLinkElementAncestor( selection.getFirstPosition() );
+		} else {
+			// The range for fully selected link is usually anchored in adjacent text nodes.
+			// Trim it to get closer to the actual link element.
+			const range = selection.getFirstRange().getTrimmed();
+			const startLink = findLinkElementAncestor( range.start );
+			const endLink = findLinkElementAncestor( range.end );
+
+			if ( !startLink || startLink != endLink ) {
+				return null;
+			}
+
+			// Check if the link element is fully selected.
+			if ( Range.createIn( startLink ).getTrimmed().isEqual( range ) ) {
+				return startLink;
+			} else {
+				return null;
+			}
+		}
+	}
+}
+
+// Returns a link element if there's one among the ancestors of the provided `Position`.
+//
+// @private
+// @param {module:engine/view/position~Position} View position to analyze.
+// @returns {module:engine/view/attributeelement~AttributeElement|null} Link element at the position or null.
+function findLinkElementAncestor( position ) {
+	return position.getAncestors().find( ancestor => isLinkElement( ancestor ) );
+}

+ 6 - 778
packages/ckeditor5-link/tests/link.js

@@ -3,788 +3,16 @@
  * For licensing, see LICENSE.md.
  */
 
-/* globals document, Event */
-
-import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
-import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
-import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
-import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
-
-import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import Link from '../src/link';
-import LinkEngine from '../src/linkengine';
-import LinkFormView from '../src/ui/linkformview';
-import LinkActionsView from '../src/ui/linkactionsview';
-import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon';
-import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
-
-import Range from '@ckeditor/ckeditor5-engine/src/view/range';
-import ClickObserver from '@ckeditor/ckeditor5-engine/src/view/observer/clickobserver';
-
-testUtils.createSinonSandbox();
+import LinkEditing from '../src/linkediting';
+import LinkUI from '../src/linkui';
 
 describe( 'Link', () => {
-	let editor, linkFeature, linkButton, balloon, formView, actionsView, editorElement;
-
-	beforeEach( () => {
-		editorElement = document.createElement( 'div' );
-		document.body.appendChild( editorElement );
-
-		return ClassicTestEditor
-			.create( editorElement, {
-				plugins: [ Link, Paragraph ]
-			} )
-			.then( newEditor => {
-				editor = newEditor;
-
-				linkFeature = editor.plugins.get( Link );
-				linkButton = editor.ui.componentFactory.create( 'link' );
-				balloon = editor.plugins.get( ContextualBalloon );
-				formView = linkFeature.formView;
-				actionsView = linkFeature.actionsView;
-
-				// There is no point to execute BalloonPanelView attachTo and pin methods so lets override it.
-				testUtils.sinon.stub( balloon.view, 'attachTo' ).returns( {} );
-				testUtils.sinon.stub( balloon.view, 'pin' ).returns( {} );
-
-				formView.render();
-			} );
-	} );
-
-	afterEach( () => {
-		editorElement.remove();
-
-		return editor.destroy();
-	} );
-
-	it( 'should be loaded', () => {
-		expect( linkFeature ).to.be.instanceOf( Link );
-	} );
-
-	it( 'should load LinkEngine', () => {
-		expect( editor.plugins.get( LinkEngine ) ).to.be.instanceOf( LinkEngine );
-	} );
-
-	it( 'should load ContextualBalloon', () => {
-		expect( editor.plugins.get( ContextualBalloon ) ).to.be.instanceOf( ContextualBalloon );
-	} );
-
-	describe( 'init()', () => {
-		it( 'should register click observer', () => {
-			expect( editor.editing.view.getObserver( ClickObserver ) ).to.be.instanceOf( ClickObserver );
-		} );
-
-		it( 'should create #actionsView', () => {
-			expect( actionsView ).to.be.instanceOf( LinkActionsView );
-		} );
-
-		it( 'should create #formView', () => {
-			expect( formView ).to.be.instanceOf( LinkFormView );
-		} );
-
-		it( 'should bind two-step caret movement to `linkHref` attribute', () => {
-			// Let's check only the minimum to not duplicated `bindTwoStepCaretToAttribute()` tests.
-			// Testing minimum is better then testing using spies that might give false positive results.
-
-			// Put selection before the link element.
-			setModelData( editor.model, '<paragraph>foo[]<$text linkHref="url">b</$text>ar</paragraph>' );
-
-			// The selection's gravity is not overridden because selection land here not as a result of `keydown`.
-			expect( editor.model.document.selection.isGravityOverridden ).to.false;
-
-			// So let's simulate `keydown` event.
-			editor.editing.view.document.fire( 'keydown', {
-				keyCode: keyCodes.arrowright,
-				preventDefault: () => {},
-				domTarget: document.body
-			} );
-
-			expect( editor.model.document.selection.isGravityOverridden ).to.true;
-		} );
-
-		describe( 'link toolbar button', () => {
-			it( 'should be registered', () => {
-				expect( linkButton ).to.be.instanceOf( ButtonView );
-			} );
-
-			it( 'should be bound to the link command', () => {
-				const command = editor.commands.get( 'link' );
-
-				command.isEnabled = true;
-				expect( linkButton.isEnabled ).to.be.true;
-
-				command.isEnabled = false;
-				expect( linkButton.isEnabled ).to.be.false;
-			} );
-
-			it( 'should call #_showUI upon #execute', () => {
-				const spy = testUtils.sinon.stub( linkFeature, '_showUI' ).returns( {} );
-
-				linkButton.fire( 'execute' );
-				sinon.assert.calledWithExactly( spy );
-			} );
-		} );
-	} );
-
-	describe( '_showUI()', () => {
-		let balloonAddSpy;
-
-		beforeEach( () => {
-			balloonAddSpy = testUtils.sinon.spy( balloon, 'add' );
-			editor.editing.view.document.isFocused = true;
-		} );
-
-		it( 'should not work if the link command is disabled', () => {
-			setModelData( editor.model, '<paragraph>f[o]o</paragraph>' );
-			editor.commands.get( 'link' ).isEnabled = false;
-
-			linkFeature._showUI();
-
-			expect( balloon.visibleView ).to.be.null;
-		} );
-
-		it( 'should not throw if the UI is already visible', () => {
-			setModelData( editor.model, '<paragraph>f[o]o</paragraph>' );
-
-			linkFeature._showUI();
-
-			expect( () => {
-				linkFeature._showUI();
-			} ).to.not.throw();
-		} );
-
-		it( 'should add #formView to the balloon and attach the balloon to the selection when text fragment is selected', () => {
-			setModelData( editor.model, '<paragraph>f[o]o</paragraph>' );
-			const selectedRange = editorElement.ownerDocument.getSelection().getRangeAt( 0 );
-
-			linkFeature._showUI();
-
-			expect( balloon.visibleView ).to.equal( formView );
-			sinon.assert.calledWithExactly( balloonAddSpy, {
-				view: formView,
-				position: {
-					target: selectedRange
-				}
-			} );
-		} );
-
-		it( 'should add #formView to the balloon and attach the balloon to the selection when selection is collapsed', () => {
-			setModelData( editor.model, '<paragraph>f[]oo</paragraph>' );
-			const selectedRange = editorElement.ownerDocument.getSelection().getRangeAt( 0 );
-
-			linkFeature._showUI();
-
-			expect( balloon.visibleView ).to.equal( formView );
-			sinon.assert.calledWithExactly( balloonAddSpy, {
-				view: formView,
-				position: {
-					target: selectedRange
-				}
-			} );
-		} );
-
-		it( 'should add #actionsView to the balloon and attach the balloon to the link element when collapsed selection is inside ' +
-			'that link',
-		() => {
-			setModelData( editor.model, '<paragraph><$text linkHref="url">f[]oo</$text></paragraph>' );
-			const linkElement = editor.editing.view.getDomRoot().querySelector( 'a' );
-
-			linkFeature._showUI();
-
-			expect( balloon.visibleView ).to.equal( actionsView );
-			sinon.assert.calledWithExactly( balloonAddSpy, {
-				view: actionsView,
-				position: {
-					target: linkElement
-				}
-			} );
-		} );
-
-		it( 'should disable #formView and #actionsView elements when link and unlink commands are disabled', () => {
-			setModelData( editor.model, '<paragraph>f[o]o</paragraph>' );
-
-			linkFeature._showUI();
-
-			editor.commands.get( 'link' ).isEnabled = true;
-			editor.commands.get( 'unlink' ).isEnabled = true;
-
-			expect( formView.urlInputView.isReadOnly ).to.be.false;
-			expect( formView.saveButtonView.isEnabled ).to.be.true;
-			expect( formView.cancelButtonView.isEnabled ).to.be.true;
-
-			expect( actionsView.unlinkButtonView.isEnabled ).to.be.true;
-			expect( actionsView.editButtonView.isEnabled ).to.be.true;
-
-			editor.commands.get( 'link' ).isEnabled = false;
-			editor.commands.get( 'unlink' ).isEnabled = false;
-
-			expect( formView.urlInputView.isReadOnly ).to.be.true;
-			expect( formView.saveButtonView.isEnabled ).to.be.false;
-			expect( formView.cancelButtonView.isEnabled ).to.be.true;
-
-			expect( actionsView.unlinkButtonView.isEnabled ).to.be.false;
-			expect( actionsView.editButtonView.isEnabled ).to.be.false;
-		} );
-
-		// https://github.com/ckeditor/ckeditor5-link/issues/78
-		it( 'should make sure the URL input in the #formView always stays in sync with the value of the command (selected link)', () => {
-			setModelData( editor.model, '<paragraph><$text linkHref="url">f[]oo</$text></paragraph>' );
-
-			// Mock some leftover value **in DOM**, e.g. after previous editing.
-			formView.urlInputView.inputView.element.value = 'leftover';
-
-			linkFeature._showUI();
-			actionsView.fire( 'edit' );
-
-			expect( formView.urlInputView.inputView.element.value ).to.equal( 'url' );
-		} );
-
-		// https://github.com/ckeditor/ckeditor5-link/issues/123
-		it( 'should make sure the URL input in the #formView always stays in sync with the value of the command (no link selected)', () => {
-			setModelData( editor.model, '<paragraph>f[]oo</paragraph>' );
-
-			linkFeature._showUI();
-			expect( formView.urlInputView.inputView.element.value ).to.equal( '' );
-		} );
-
-		describe( 'response to view#change', () => {
-			let view, viewDocument;
-
-			beforeEach( () => {
-				view = editor.editing.view;
-				viewDocument = view.document;
-			} );
-
-			it( 'should not duplicate #change listeners', () => {
-				setModelData( editor.model, '<paragraph>f[]oo</paragraph>' );
-
-				const spy = testUtils.sinon.stub( balloon, 'updatePosition' ).returns( {} );
-
-				linkFeature._showUI();
-				view.change( () => {} );
-				linkFeature._hideUI();
-
-				linkFeature._showUI();
-				view.change( () => {} );
-				sinon.assert.calledTwice( spy );
-			} );
-
-			// https://github.com/ckeditor/ckeditor5-link/issues/113
-			it( 'updates the position of the panel – editing a link, then the selection remains in the link', () => {
-				setModelData( editor.model, '<paragraph><$text linkHref="url">f[]oo</$text></paragraph>' );
-
-				linkFeature._showUI();
-				const spy = testUtils.sinon.stub( balloon, 'updatePosition' ).returns( {} );
-
-				const root = viewDocument.getRoot();
-				const text = root.getChild( 0 ).getChild( 0 ).getChild( 0 );
-
-				// Move selection to foo[].
-				view.change( writer => {
-					writer.setSelection( Range.createFromParentsAndOffsets( text, 3, text, 3 ), true );
-				} );
-
-				sinon.assert.calledOnce( spy );
-				sinon.assert.calledWithExactly( spy, {
-					target: view.domConverter.mapViewToDom( root.getChild( 0 ).getChild( 0 ) )
-				} );
-			} );
-
-			// https://github.com/ckeditor/ckeditor5-link/issues/113
-			it( 'updates the position of the panel – creating a new link, then the selection moved', () => {
-				setModelData( editor.model, '<paragraph>f[]oo</paragraph>' );
-
-				linkFeature._showUI();
-				const spy = testUtils.sinon.stub( balloon, 'updatePosition' ).returns( {} );
-
-				const root = viewDocument.getRoot();
-				const text = root.getChild( 0 ).getChild( 0 );
-
-				view.change( writer => {
-					writer.setSelection( Range.createFromParentsAndOffsets( text, 3, text, 3 ), true );
-				} );
-
-				sinon.assert.calledOnce( spy );
-				sinon.assert.calledWithExactly( spy, {
-					target: editorElement.ownerDocument.getSelection().getRangeAt( 0 )
-				} );
-			} );
-
-			// https://github.com/ckeditor/ckeditor5-link/issues/113
-			it( 'hides of the panel – editing a link, then the selection moved out of the link', () => {
-				setModelData( editor.model, '<paragraph><$text linkHref="url">f[]oo</$text>bar</paragraph>' );
-
-				linkFeature._showUI();
-
-				const spyUpdate = testUtils.sinon.stub( balloon, 'updatePosition' ).returns( {} );
-				const spyHide = testUtils.sinon.spy( linkFeature, '_hideUI' );
-
-				const root = viewDocument.getRoot();
-				const text = root.getChild( 0 ).getChild( 1 );
-
-				// Move selection to b[]ar.
-				view.change( writer => {
-					writer.setSelection( Range.createFromParentsAndOffsets( text, 1, text, 1 ), true );
-				} );
-
-				sinon.assert.calledOnce( spyHide );
-				sinon.assert.notCalled( spyUpdate );
-			} );
-
-			// https://github.com/ckeditor/ckeditor5-link/issues/113
-			it( 'hides the panel – editing a link, then the selection expands', () => {
-				setModelData( editor.model, '<paragraph><$text linkHref="url">f[]oo</$text></paragraph>' );
-
-				linkFeature._showUI();
-
-				const spyUpdate = testUtils.sinon.stub( balloon, 'updatePosition' ).returns( {} );
-				const spyHide = testUtils.sinon.spy( linkFeature, '_hideUI' );
-
-				const root = viewDocument.getRoot();
-				const text = root.getChild( 0 ).getChild( 0 ).getChild( 0 );
-
-				// Move selection to f[o]o.
-				view.change( writer => {
-					writer.setSelection( Range.createFromParentsAndOffsets( text, 1, text, 2 ), true );
-				} );
-
-				sinon.assert.calledOnce( spyHide );
-				sinon.assert.notCalled( spyUpdate );
-			} );
-
-			// https://github.com/ckeditor/ckeditor5-link/issues/113
-			it( 'hides the panel – creating a new link, then the selection moved to another parent', () => {
-				setModelData( editor.model, '<paragraph>f[]oo</paragraph><paragraph>bar</paragraph>' );
-
-				linkFeature._showUI();
-
-				const spyUpdate = testUtils.sinon.stub( balloon, 'updatePosition' ).returns( {} );
-				const spyHide = testUtils.sinon.spy( linkFeature, '_hideUI' );
-
-				const root = viewDocument.getRoot();
-				const text = root.getChild( 1 ).getChild( 0 );
-
-				// Move selection to f[o]o.
-				view.change( writer => {
-					writer.setSelection( Range.createFromParentsAndOffsets( text, 1, text, 2 ), true );
-				} );
-
-				sinon.assert.calledOnce( spyHide );
-				sinon.assert.notCalled( spyUpdate );
-			} );
-		} );
-	} );
-
-	describe( '_hideUI()', () => {
-		beforeEach( () => {
-			linkFeature._showUI();
-		} );
-
-		it( 'should remove the UI from the balloon', () => {
-			expect( balloon.hasView( formView ) ).to.be.true;
-			expect( balloon.hasView( actionsView ) ).to.be.true;
-
-			linkFeature._hideUI();
-
-			expect( balloon.hasView( formView ) ).to.be.false;
-			expect( balloon.hasView( actionsView ) ).to.be.false;
-		} );
-
-		it( 'should focus the `editable` by default', () => {
-			const spy = testUtils.sinon.spy( editor.editing.view, 'focus' );
-
-			linkFeature._hideUI();
-
-			// First call is from _removeFormView.
-			sinon.assert.calledTwice( spy );
-		} );
-
-		it( 'should not throw an error when views are not in the `balloon`', () => {
-			linkFeature._hideUI();
-
-			expect( () => {
-				linkFeature._hideUI();
-			} ).to.not.throw();
-		} );
-
-		it( 'should clear #render listener from the ViewDocument', () => {
-			const spy = sinon.spy();
-
-			linkFeature.listenTo( editor.editing.view, 'render', spy );
-			linkFeature._hideUI();
-			editor.editing.view.change( () => {} );
-
-			sinon.assert.notCalled( spy );
-		} );
-	} );
-
-	describe( 'keyboard support', () => {
-		it( 'should show the UI on Ctrl+K keystroke', () => {
-			const spy = testUtils.sinon.stub( linkFeature, '_showUI' ).returns( {} );
-			const command = editor.commands.get( 'link' );
-
-			command.isEnabled = false;
-
-			editor.keystrokes.press( {
-				keyCode: keyCodes.k,
-				ctrlKey: true,
-				preventDefault: sinon.spy(),
-				stopPropagation: sinon.spy()
-			} );
-			sinon.assert.notCalled( spy );
-
-			command.isEnabled = true;
-
-			editor.keystrokes.press( {
-				keyCode: keyCodes.k,
-				ctrlKey: true,
-				preventDefault: sinon.spy(),
-				stopPropagation: sinon.spy()
-			} );
-			sinon.assert.calledWithExactly( spy );
-		} );
-
-		it( 'should prevent default action on Ctrl+K keystroke', () => {
-			const preventDefaultSpy = sinon.spy();
-			const stopPropagationSpy = sinon.spy();
-
-			editor.keystrokes.press( {
-				keyCode: keyCodes.k,
-				ctrlKey: true,
-				preventDefault: preventDefaultSpy,
-				stopPropagation: stopPropagationSpy
-			} );
-
-			sinon.assert.calledOnce( preventDefaultSpy );
-			sinon.assert.calledOnce( stopPropagationSpy );
-		} );
-
-		it( 'should focus the the #actionsView on `Tab` key press when #actionsView is visible', () => {
-			const keyEvtData = {
-				keyCode: keyCodes.tab,
-				preventDefault: sinon.spy(),
-				stopPropagation: sinon.spy()
-			};
-
-			const normalPriorityTabCallbackSpy = sinon.spy();
-			const highestPriorityTabCallbackSpy = sinon.spy();
-			editor.keystrokes.set( 'Tab', normalPriorityTabCallbackSpy );
-			editor.keystrokes.set( 'Tab', highestPriorityTabCallbackSpy, { priority: 'highest' } );
-
-			// Balloon is invisible, form not focused.
-			actionsView.focusTracker.isFocused = false;
-
-			const spy = sinon.spy( actionsView, 'focus' );
-
-			editor.keystrokes.press( keyEvtData );
-			sinon.assert.notCalled( keyEvtData.preventDefault );
-			sinon.assert.notCalled( keyEvtData.stopPropagation );
-			sinon.assert.notCalled( spy );
-			sinon.assert.calledOnce( normalPriorityTabCallbackSpy );
-			sinon.assert.calledOnce( highestPriorityTabCallbackSpy );
-
-			// Balloon is visible, form focused.
-			linkFeature._showUI();
-			testUtils.sinon.stub( linkFeature, '_areActionsVisible' ).value( true );
-
-			actionsView.focusTracker.isFocused = true;
-
-			editor.keystrokes.press( keyEvtData );
-			sinon.assert.notCalled( keyEvtData.preventDefault );
-			sinon.assert.notCalled( keyEvtData.stopPropagation );
-			sinon.assert.notCalled( spy );
-			sinon.assert.calledTwice( normalPriorityTabCallbackSpy );
-			sinon.assert.calledTwice( highestPriorityTabCallbackSpy );
-
-			// Balloon is still visible, form not focused.
-			actionsView.focusTracker.isFocused = false;
-
-			editor.keystrokes.press( keyEvtData );
-			sinon.assert.calledOnce( keyEvtData.preventDefault );
-			sinon.assert.calledOnce( keyEvtData.stopPropagation );
-			sinon.assert.calledOnce( spy );
-			sinon.assert.calledTwice( normalPriorityTabCallbackSpy );
-			sinon.assert.calledThrice( highestPriorityTabCallbackSpy );
-		} );
-
-		it( 'should hide the UI after Esc key press (from editor) and not focus the editable', () => {
-			const spy = testUtils.sinon.spy( linkFeature, '_hideUI' );
-			const keyEvtData = {
-				keyCode: keyCodes.esc,
-				preventDefault: sinon.spy(),
-				stopPropagation: sinon.spy()
-			};
-
-			// Balloon is visible.
-			linkFeature._showUI();
-			editor.keystrokes.press( keyEvtData );
-
-			sinon.assert.calledWithExactly( spy );
-		} );
-
-		it( 'should not hide the UI after Esc key press (from editor) when UI is open but is not visible', () => {
-			const spy = testUtils.sinon.spy( linkFeature, '_hideUI' );
-			const keyEvtData = {
-				keyCode: keyCodes.esc,
-				preventDefault: () => {},
-				stopPropagation: () => {}
-			};
-
-			const viewMock = {
-				ready: true,
-				render: () => {},
-				destroy: () => {}
-			};
-
-			linkFeature._showUI();
-
-			// Some view precedes the link UI in the balloon.
-			balloon.add( { view: viewMock } );
-			editor.keystrokes.press( keyEvtData );
-
-			sinon.assert.notCalled( spy );
-		} );
+	it( 'should require LinkEditing and LinkUI', () => {
+		expect( Link.requires ).to.deep.equal( [ LinkEditing, LinkUI ] );
 	} );
 
-	describe( 'mouse support', () => {
-		it( 'should hide the UI and not focus editable upon clicking outside the UI', () => {
-			const spy = testUtils.sinon.spy( linkFeature, '_hideUI' );
-
-			linkFeature._showUI( true );
-			document.body.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) );
-
-			sinon.assert.calledWithExactly( spy );
-		} );
-
-		it( 'should not hide the UI upon clicking inside the the UI', () => {
-			const spy = testUtils.sinon.spy( linkFeature, '_hideUI' );
-
-			linkFeature._showUI( true );
-			balloon.view.element.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) );
-
-			sinon.assert.notCalled( spy );
-		} );
-
-		describe( 'clicking on editable', () => {
-			let observer, spy;
-
-			beforeEach( () => {
-				observer = editor.editing.view.getObserver( ClickObserver );
-				editor.model.schema.extend( '$text', { allowIn: '$root' } );
-
-				spy = testUtils.sinon.stub( linkFeature, '_showUI' ).returns( {} );
-			} );
-
-			it( 'should show the UI when collapsed selection is inside link element', () => {
-				setModelData( editor.model, '<$text linkHref="url">fo[]o</$text>' );
-
-				observer.fire( 'click', { target: document.body } );
-				sinon.assert.calledWithExactly( spy );
-			} );
-
-			it( 'should show the UI when selection exclusively encloses a link element (#1)', () => {
-				setModelData( editor.model, '[<$text linkHref="url">foo</$text>]' );
-
-				observer.fire( 'click', { target: {} } );
-				sinon.assert.calledWithExactly( spy );
-			} );
-
-			it( 'should show the UI when selection exclusively encloses a link element (#2)', () => {
-				setModelData( editor.model, '<$text linkHref="url">[foo]</$text>' );
-
-				observer.fire( 'click', { target: {} } );
-				sinon.assert.calledWithExactly( spy );
-			} );
-
-			it( 'should do nothing when selection is not inside link element', () => {
-				setModelData( editor.model, '[]' );
-
-				observer.fire( 'click', { target: {} } );
-				sinon.assert.notCalled( spy );
-			} );
-
-			it( 'should do nothing when selection is non-collapsed and doesn\'t enclose a link element (#1)', () => {
-				setModelData( editor.model, '<$text linkHref="url">f[o]o</$text>' );
-
-				observer.fire( 'click', { target: {} } );
-				sinon.assert.notCalled( spy );
-			} );
-
-			it( 'should do nothing when selection is non-collapsed and doesn\'t enclose a link element (#2)', () => {
-				setModelData( editor.model, '<$text linkHref="url">[fo]o</$text>' );
-
-				observer.fire( 'click', { target: {} } );
-				sinon.assert.notCalled( spy );
-			} );
-
-			it( 'should do nothing when selection is non-collapsed and doesn\'t enclose a link element (#3)', () => {
-				setModelData( editor.model, '<$text linkHref="url">f[oo]</$text>' );
-
-				observer.fire( 'click', { target: {} } );
-				sinon.assert.notCalled( spy );
-			} );
-
-			it( 'should do nothing when selection is non-collapsed and doesn\'t enclose a link element (#4)', () => {
-				setModelData( editor.model, 'ba[r<$text linkHref="url">foo]</$text>' );
-
-				observer.fire( 'click', { target: {} } );
-				sinon.assert.notCalled( spy );
-			} );
-
-			it( 'should do nothing when selection is non-collapsed and doesn\'t enclose a link element (#5)', () => {
-				setModelData( editor.model, 'ba[r<$text linkHref="url">foo</$text>]' );
-
-				observer.fire( 'click', { target: {} } );
-				sinon.assert.notCalled( spy );
-			} );
-		} );
-	} );
-
-	describe( 'actions view', () => {
-		let focusEditableSpy;
-
-		beforeEach( () => {
-			focusEditableSpy = testUtils.sinon.spy( editor.editing.view, 'focus' );
-		} );
-
-		it( 'should mark the editor UI as focused when the #actionsView is focused', () => {
-			linkFeature._showUI();
-			linkFeature._removeFormView();
-
-			expect( balloon.visibleView ).to.equal( actionsView );
-
-			editor.ui.focusTracker.isFocused = false;
-			actionsView.element.dispatchEvent( new Event( 'focus' ) );
-
-			expect( editor.ui.focusTracker.isFocused ).to.be.true;
-		} );
-
-		describe( 'binding', () => {
-			it( 'should show the #formView on #edit event and select the URL input field', () => {
-				linkFeature._showUI();
-				linkFeature._removeFormView();
-
-				const selectSpy = testUtils.sinon.spy( formView.urlInputView, 'select' );
-				actionsView.fire( 'edit' );
-
-				expect( balloon.visibleView ).to.equal( formView );
-				sinon.assert.calledOnce( selectSpy );
-			} );
-
-			it( 'should execute unlink command on actionsView#unlink event', () => {
-				const executeSpy = testUtils.sinon.spy( editor, 'execute' );
-
-				actionsView.fire( 'unlink' );
-
-				expect( executeSpy.calledOnce ).to.be.true;
-				expect( executeSpy.calledWithExactly( 'unlink' ) ).to.be.true;
-			} );
-
-			it( 'should hide and focus editable on actionsView#unlink event', () => {
-				linkFeature._showUI();
-				linkFeature._removeFormView();
-
-				// Removing the form would call the focus spy.
-				focusEditableSpy.resetHistory();
-				actionsView.fire( 'unlink' );
-
-				expect( balloon.visibleView ).to.be.null;
-				expect( focusEditableSpy.calledOnce ).to.be.true;
-			} );
-
-			it( 'should hide after Esc key press', () => {
-				const keyEvtData = {
-					keyCode: keyCodes.esc,
-					preventDefault: sinon.spy(),
-					stopPropagation: sinon.spy()
-				};
-
-				linkFeature._showUI();
-				linkFeature._removeFormView();
-
-				// Removing the form would call the focus spy.
-				focusEditableSpy.resetHistory();
-
-				actionsView.keystrokes.press( keyEvtData );
-				expect( balloon.visibleView ).to.equal( null );
-				expect( focusEditableSpy.calledOnce ).to.be.true;
-			} );
-		} );
-	} );
-
-	describe( 'link form view', () => {
-		let focusEditableSpy;
-
-		beforeEach( () => {
-			focusEditableSpy = testUtils.sinon.spy( editor.editing.view, 'focus' );
-		} );
-
-		it( 'should mark the editor UI as focused when the #formView is focused', () => {
-			linkFeature._showUI();
-			expect( balloon.visibleView ).to.equal( formView );
-
-			editor.ui.focusTracker.isFocused = false;
-			formView.element.dispatchEvent( new Event( 'focus' ) );
-
-			expect( editor.ui.focusTracker.isFocused ).to.be.true;
-		} );
-
-		describe( 'binding', () => {
-			beforeEach( () => {
-				setModelData( editor.model, '<paragraph>f[o]o</paragraph>' );
-			} );
-
-			it( 'should bind formView.urlInputView#value to link command value', () => {
-				const command = editor.commands.get( 'link' );
-
-				expect( formView.urlInputView.value ).to.undefined;
-
-				command.value = 'http://cksource.com';
-				expect( formView.urlInputView.value ).to.equal( 'http://cksource.com' );
-			} );
-
-			it( 'should execute link command on formView#submit event', () => {
-				const executeSpy = testUtils.sinon.spy( editor, 'execute' );
-
-				formView.urlInputView.value = 'http://ckeditor.com';
-				expect( formView.urlInputView.inputView.element.value ).to.equal( 'http://ckeditor.com' );
-
-				formView.urlInputView.inputView.element.value = 'http://cksource.com';
-				formView.fire( 'submit' );
-
-				expect( executeSpy.calledOnce ).to.be.true;
-				expect( executeSpy.calledWithExactly( 'link', 'http://cksource.com' ) ).to.be.true;
-			} );
-
-			it( 'should hide and reveal the #actionsView on formView#submit event', () => {
-				linkFeature._showUI();
-				formView.fire( 'submit' );
-
-				expect( balloon.visibleView ).to.equal( actionsView );
-				expect( focusEditableSpy.calledOnce ).to.be.true;
-			} );
-
-			it( 'should hide and reveal the #actionsView on formView#cancel event', () => {
-				linkFeature._showUI();
-				formView.fire( 'cancel' );
-
-				expect( balloon.visibleView ).to.equal( actionsView );
-				expect( focusEditableSpy.calledOnce ).to.be.true;
-			} );
-
-			it( 'should hide after Esc key press', () => {
-				const keyEvtData = {
-					keyCode: keyCodes.esc,
-					preventDefault: sinon.spy(),
-					stopPropagation: sinon.spy()
-				};
-
-				linkFeature._showUI();
-
-				formView.keystrokes.press( keyEvtData );
-				expect( balloon.visibleView ).to.equal( actionsView );
-				expect( focusEditableSpy.calledOnce ).to.be.true;
-			} );
-		} );
+	it( 'should be named', () => {
+		expect( Link.pluginName ).to.equal( 'Link' );
 	} );
 } );

+ 27 - 4
packages/ckeditor5-link/tests/linkengine.js

@@ -3,7 +3,7 @@
  * For licensing, see LICENSE.md.
  */
 
-import LinkEngine from '../src/linkengine';
+import LinkEditing from '../src/linkediting';
 import LinkCommand from '../src/linkcommand';
 import UnlinkCommand from '../src/unlinkcommand';
 
@@ -12,16 +12,19 @@ import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
 import { isLinkElement } from '../src/utils';
+import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
 
 import { downcastAttributeToElement } from '@ckeditor/ckeditor5-engine/src/conversion/downcast-converters';
 
-describe( 'LinkEngine', () => {
+/* global document */
+
+describe( 'LinkEditing', () => {
 	let editor, model;
 
 	beforeEach( () => {
 		return VirtualTestEditor
 			.create( {
-				plugins: [ Paragraph, LinkEngine ]
+				plugins: [ Paragraph, LinkEditing ]
 			} )
 			.then( newEditor => {
 				editor = newEditor;
@@ -30,7 +33,7 @@ describe( 'LinkEngine', () => {
 	} );
 
 	it( 'should be loaded', () => {
-		expect( editor.plugins.get( LinkEngine ) ).to.be.instanceOf( LinkEngine );
+		expect( editor.plugins.get( LinkEditing ) ).to.be.instanceOf( LinkEditing );
 	} );
 
 	it( 'should set proper schema rules', () => {
@@ -40,6 +43,26 @@ describe( 'LinkEngine', () => {
 		expect( model.schema.checkAttribute( [ '$block' ], 'linkHref' ) ).to.be.false;
 	} );
 
+	it( 'should bind two-step caret movement to `linkHref` attribute', () => {
+		// Let's check only the minimum to not duplicated `bindTwoStepCaretToAttribute()` tests.
+		// Testing minimum is better then testing using spies that might give false positive results.
+
+		// Put selection before the link element.
+		setModelData( editor.model, '<paragraph>foo[]<$text linkHref="url">b</$text>ar</paragraph>' );
+
+		// The selection's gravity is not overridden because selection land here not as a result of `keydown`.
+		expect( editor.model.document.selection.isGravityOverridden ).to.false;
+
+		// So let's simulate `keydown` event.
+		editor.editing.view.document.fire( 'keydown', {
+			keyCode: keyCodes.arrowright,
+			preventDefault: () => {},
+			domTarget: document.body
+		} );
+
+		expect( editor.model.document.selection.isGravityOverridden ).to.true;
+	} );
+
 	describe( 'command', () => {
 		it( 'should register link command', () => {
 			const command = editor.commands.get( 'link' );

+ 763 - 0
packages/ckeditor5-link/tests/linkui.js

@@ -0,0 +1,763 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* globals document, Event */
+
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard';
+import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import LinkEditing from '../src/linkediting';
+import LinkUI from '../src/linkui';
+import LinkFormView from '../src/ui/linkformview';
+import LinkActionsView from '../src/ui/linkactionsview';
+import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon';
+import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
+
+import Range from '@ckeditor/ckeditor5-engine/src/view/range';
+import ClickObserver from '@ckeditor/ckeditor5-engine/src/view/observer/clickobserver';
+
+testUtils.createSinonSandbox();
+
+describe( 'LinkUI', () => {
+	let editor, linkUIFeature, linkButton, balloon, formView, actionsView, editorElement;
+
+	beforeEach( () => {
+		editorElement = document.createElement( 'div' );
+		document.body.appendChild( editorElement );
+
+		return ClassicTestEditor
+			.create( editorElement, {
+				plugins: [ LinkEditing, LinkUI, Paragraph ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+
+				linkUIFeature = editor.plugins.get( LinkUI );
+				linkButton = editor.ui.componentFactory.create( 'link' );
+				balloon = editor.plugins.get( ContextualBalloon );
+				formView = linkUIFeature.formView;
+				actionsView = linkUIFeature.actionsView;
+
+				// There is no point to execute BalloonPanelView attachTo and pin methods so lets override it.
+				testUtils.sinon.stub( balloon.view, 'attachTo' ).returns( {} );
+				testUtils.sinon.stub( balloon.view, 'pin' ).returns( {} );
+
+				formView.render();
+			} );
+	} );
+
+	afterEach( () => {
+		editorElement.remove();
+
+		return editor.destroy();
+	} );
+
+	it( 'should load ContextualBalloon', () => {
+		expect( editor.plugins.get( ContextualBalloon ) ).to.be.instanceOf( ContextualBalloon );
+	} );
+
+	describe( 'init', () => {
+		it( 'should register click observer', () => {
+			expect( editor.editing.view.getObserver( ClickObserver ) ).to.be.instanceOf( ClickObserver );
+		} );
+
+		it( 'should create #actionsView', () => {
+			expect( actionsView ).to.be.instanceOf( LinkActionsView );
+		} );
+
+		it( 'should create #formView', () => {
+			expect( formView ).to.be.instanceOf( LinkFormView );
+		} );
+
+		describe( 'link toolbar button', () => {
+			it( 'should be registered', () => {
+				expect( linkButton ).to.be.instanceOf( ButtonView );
+			} );
+
+			it( 'should be bound to the link command', () => {
+				const command = editor.commands.get( 'link' );
+
+				command.isEnabled = true;
+				expect( linkButton.isEnabled ).to.be.true;
+
+				command.isEnabled = false;
+				expect( linkButton.isEnabled ).to.be.false;
+			} );
+
+			it( 'should call #_showUI upon #execute', () => {
+				const spy = testUtils.sinon.stub( linkUIFeature, '_showUI' ).returns( {} );
+
+				linkButton.fire( 'execute' );
+				sinon.assert.calledWithExactly( spy );
+			} );
+		} );
+	} );
+
+	describe( '_showUI()', () => {
+		let balloonAddSpy;
+
+		beforeEach( () => {
+			balloonAddSpy = testUtils.sinon.spy( balloon, 'add' );
+			editor.editing.view.document.isFocused = true;
+		} );
+
+		it( 'should not work if the link command is disabled', () => {
+			setModelData( editor.model, '<paragraph>f[o]o</paragraph>' );
+			editor.commands.get( 'link' ).isEnabled = false;
+
+			linkUIFeature._showUI();
+
+			expect( balloon.visibleView ).to.be.null;
+		} );
+
+		it( 'should not throw if the UI is already visible', () => {
+			setModelData( editor.model, '<paragraph>f[o]o</paragraph>' );
+
+			linkUIFeature._showUI();
+
+			expect( () => {
+				linkUIFeature._showUI();
+			} ).to.not.throw();
+		} );
+
+		it( 'should add #formView to the balloon and attach the balloon to the selection when text fragment is selected', () => {
+			setModelData( editor.model, '<paragraph>f[o]o</paragraph>' );
+			const selectedRange = editorElement.ownerDocument.getSelection().getRangeAt( 0 );
+
+			linkUIFeature._showUI();
+
+			expect( balloon.visibleView ).to.equal( formView );
+			sinon.assert.calledWithExactly( balloonAddSpy, {
+				view: formView,
+				position: {
+					target: selectedRange
+				}
+			} );
+		} );
+
+		it( 'should add #formView to the balloon and attach the balloon to the selection when selection is collapsed', () => {
+			setModelData( editor.model, '<paragraph>f[]oo</paragraph>' );
+			const selectedRange = editorElement.ownerDocument.getSelection().getRangeAt( 0 );
+
+			linkUIFeature._showUI();
+
+			expect( balloon.visibleView ).to.equal( formView );
+			sinon.assert.calledWithExactly( balloonAddSpy, {
+				view: formView,
+				position: {
+					target: selectedRange
+				}
+			} );
+		} );
+
+		it( 'should add #actionsView to the balloon and attach the balloon to the link element when collapsed selection is inside ' +
+			'that link',
+		() => {
+			setModelData( editor.model, '<paragraph><$text linkHref="url">f[]oo</$text></paragraph>' );
+			const linkElement = editor.editing.view.getDomRoot().querySelector( 'a' );
+
+			linkUIFeature._showUI();
+
+			expect( balloon.visibleView ).to.equal( actionsView );
+			sinon.assert.calledWithExactly( balloonAddSpy, {
+				view: actionsView,
+				position: {
+					target: linkElement
+				}
+			} );
+		} );
+
+		it( 'should disable #formView and #actionsView elements when link and unlink commands are disabled', () => {
+			setModelData( editor.model, '<paragraph>f[o]o</paragraph>' );
+
+			linkUIFeature._showUI();
+
+			editor.commands.get( 'link' ).isEnabled = true;
+			editor.commands.get( 'unlink' ).isEnabled = true;
+
+			expect( formView.urlInputView.isReadOnly ).to.be.false;
+			expect( formView.saveButtonView.isEnabled ).to.be.true;
+			expect( formView.cancelButtonView.isEnabled ).to.be.true;
+
+			expect( actionsView.unlinkButtonView.isEnabled ).to.be.true;
+			expect( actionsView.editButtonView.isEnabled ).to.be.true;
+
+			editor.commands.get( 'link' ).isEnabled = false;
+			editor.commands.get( 'unlink' ).isEnabled = false;
+
+			expect( formView.urlInputView.isReadOnly ).to.be.true;
+			expect( formView.saveButtonView.isEnabled ).to.be.false;
+			expect( formView.cancelButtonView.isEnabled ).to.be.true;
+
+			expect( actionsView.unlinkButtonView.isEnabled ).to.be.false;
+			expect( actionsView.editButtonView.isEnabled ).to.be.false;
+		} );
+
+		// https://github.com/ckeditor/ckeditor5-link/issues/78
+		it( 'should make sure the URL input in the #formView always stays in sync with the value of the command (selected link)', () => {
+			setModelData( editor.model, '<paragraph><$text linkHref="url">f[]oo</$text></paragraph>' );
+
+			// Mock some leftover value **in DOM**, e.g. after previous editing.
+			formView.urlInputView.inputView.element.value = 'leftover';
+
+			linkUIFeature._showUI();
+			actionsView.fire( 'edit' );
+
+			expect( formView.urlInputView.inputView.element.value ).to.equal( 'url' );
+		} );
+
+		// https://github.com/ckeditor/ckeditor5-link/issues/123
+		it( 'should make sure the URL input in the #formView always stays in sync with the value of the command (no link selected)', () => {
+			setModelData( editor.model, '<paragraph>f[]oo</paragraph>' );
+
+			linkUIFeature._showUI();
+			expect( formView.urlInputView.inputView.element.value ).to.equal( '' );
+		} );
+
+		describe( 'response to view#change', () => {
+			let view, viewDocument;
+
+			beforeEach( () => {
+				view = editor.editing.view;
+				viewDocument = view.document;
+			} );
+
+			it( 'should not duplicate #change listeners', () => {
+				setModelData( editor.model, '<paragraph>f[]oo</paragraph>' );
+
+				const spy = testUtils.sinon.stub( balloon, 'updatePosition' ).returns( {} );
+
+				linkUIFeature._showUI();
+				view.render();
+				linkUIFeature._hideUI();
+
+				linkUIFeature._showUI();
+				view.render();
+				sinon.assert.calledTwice( spy );
+			} );
+
+			// https://github.com/ckeditor/ckeditor5-link/issues/113
+			it( 'updates the position of the panel – editing a link, then the selection remains in the link', () => {
+				setModelData( editor.model, '<paragraph><$text linkHref="url">f[]oo</$text></paragraph>' );
+
+				linkUIFeature._showUI();
+				const spy = testUtils.sinon.stub( balloon, 'updatePosition' ).returns( {} );
+
+				const root = viewDocument.getRoot();
+				const text = root.getChild( 0 ).getChild( 0 ).getChild( 0 );
+
+				// Move selection to foo[].
+				view.change( writer => {
+					writer.setSelection( Range.createFromParentsAndOffsets( text, 3, text, 3 ), true );
+				} );
+
+				sinon.assert.calledOnce( spy );
+				sinon.assert.calledWithExactly( spy, {
+					target: view.domConverter.mapViewToDom( root.getChild( 0 ).getChild( 0 ) )
+				} );
+			} );
+
+			// https://github.com/ckeditor/ckeditor5-link/issues/113
+			it( 'updates the position of the panel – creating a new link, then the selection moved', () => {
+				setModelData( editor.model, '<paragraph>f[]oo</paragraph>' );
+
+				linkUIFeature._showUI();
+				const spy = testUtils.sinon.stub( balloon, 'updatePosition' ).returns( {} );
+
+				// Fires #render.
+				const root = viewDocument.getRoot();
+				const text = root.getChild( 0 ).getChild( 0 );
+
+				view.change( writer => {
+					writer.setSelection( Range.createFromParentsAndOffsets( text, 3, text, 3 ), true );
+				} );
+
+				sinon.assert.calledOnce( spy );
+				sinon.assert.calledWithExactly( spy, {
+					target: editorElement.ownerDocument.getSelection().getRangeAt( 0 )
+				} );
+			} );
+
+			// https://github.com/ckeditor/ckeditor5-link/issues/113
+			it( 'hides of the panel – editing a link, then the selection moved out of the link', () => {
+				setModelData( editor.model, '<paragraph><$text linkHref="url">f[]oo</$text>bar</paragraph>' );
+
+				linkUIFeature._showUI();
+
+				const spyUpdate = testUtils.sinon.stub( balloon, 'updatePosition' ).returns( {} );
+				const spyHide = testUtils.sinon.spy( linkUIFeature, '_hideUI' );
+
+				const root = viewDocument.getRoot();
+				const text = root.getChild( 0 ).getChild( 1 );
+
+				// Move selection to b[]ar.
+				view.change( writer => {
+					writer.setSelection( Range.createFromParentsAndOffsets( text, 1, text, 1 ), true );
+				} );
+
+				sinon.assert.calledOnce( spyHide );
+				sinon.assert.notCalled( spyUpdate );
+			} );
+
+			// https://github.com/ckeditor/ckeditor5-link/issues/113
+			it( 'hides the panel – editing a link, then the selection expands', () => {
+				setModelData( editor.model, '<paragraph><$text linkHref="url">f[]oo</$text></paragraph>' );
+
+				linkUIFeature._showUI();
+
+				const spyUpdate = testUtils.sinon.stub( balloon, 'updatePosition' ).returns( {} );
+				const spyHide = testUtils.sinon.spy( linkUIFeature, '_hideUI' );
+
+				const root = viewDocument.getRoot();
+				const text = root.getChild( 0 ).getChild( 0 ).getChild( 0 );
+
+				// Move selection to f[o]o.
+				view.change( writer => {
+					writer.setSelection( Range.createFromParentsAndOffsets( text, 1, text, 2 ), true );
+				} );
+
+				sinon.assert.calledOnce( spyHide );
+				sinon.assert.notCalled( spyUpdate );
+			} );
+
+			// https://github.com/ckeditor/ckeditor5-link/issues/113
+			it( 'hides the panel – creating a new link, then the selection moved to another parent', () => {
+				setModelData( editor.model, '<paragraph>f[]oo</paragraph><paragraph>bar</paragraph>' );
+
+				linkUIFeature._showUI();
+
+				const spyUpdate = testUtils.sinon.stub( balloon, 'updatePosition' ).returns( {} );
+				const spyHide = testUtils.sinon.spy( linkUIFeature, '_hideUI' );
+
+				const root = viewDocument.getRoot();
+				const text = root.getChild( 1 ).getChild( 0 );
+
+				// Move selection to f[o]o.
+				view.change( writer => {
+					writer.setSelection( Range.createFromParentsAndOffsets( text, 1, text, 2 ), true );
+				} );
+
+				sinon.assert.calledOnce( spyHide );
+				sinon.assert.notCalled( spyUpdate );
+			} );
+		} );
+	} );
+
+	describe( '_hideUI()', () => {
+		beforeEach( () => {
+			linkUIFeature._showUI();
+		} );
+
+		it( 'should remove the UI from the balloon', () => {
+			expect( balloon.hasView( formView ) ).to.be.true;
+			expect( balloon.hasView( actionsView ) ).to.be.true;
+
+			linkUIFeature._hideUI();
+
+			expect( balloon.hasView( formView ) ).to.be.false;
+			expect( balloon.hasView( actionsView ) ).to.be.false;
+		} );
+
+		it( 'should focus the `editable` by default', () => {
+			const spy = testUtils.sinon.spy( editor.editing.view, 'focus' );
+
+			linkUIFeature._hideUI();
+
+			// First call is from _removeFormView.
+			sinon.assert.calledTwice( spy );
+		} );
+
+		it( 'should not throw an error when views are not in the `balloon`', () => {
+			linkUIFeature._hideUI();
+
+			expect( () => {
+				linkUIFeature._hideUI();
+			} ).to.not.throw();
+		} );
+
+		it( 'should clear #render listener from the ViewDocument', () => {
+			const spy = sinon.spy();
+
+			linkUIFeature.listenTo( editor.editing.view, 'render', spy );
+			linkUIFeature._hideUI();
+			editor.editing.view.change( () => {} );
+
+			sinon.assert.notCalled( spy );
+		} );
+	} );
+
+	describe( 'keyboard support', () => {
+		it( 'should show the UI on Ctrl+K keystroke', () => {
+			const spy = testUtils.sinon.stub( linkUIFeature, '_showUI' ).returns( {} );
+			const command = editor.commands.get( 'link' );
+
+			command.isEnabled = false;
+
+			editor.keystrokes.press( {
+				keyCode: keyCodes.k,
+				ctrlKey: true,
+				preventDefault: sinon.spy(),
+				stopPropagation: sinon.spy()
+			} );
+			sinon.assert.notCalled( spy );
+
+			command.isEnabled = true;
+
+			editor.keystrokes.press( {
+				keyCode: keyCodes.k,
+				ctrlKey: true,
+				preventDefault: sinon.spy(),
+				stopPropagation: sinon.spy()
+			} );
+			sinon.assert.calledWithExactly( spy );
+		} );
+
+		it( 'should prevent default action on Ctrl+K keystroke', () => {
+			const preventDefaultSpy = sinon.spy();
+			const stopPropagationSpy = sinon.spy();
+
+			editor.keystrokes.press( {
+				keyCode: keyCodes.k,
+				ctrlKey: true,
+				preventDefault: preventDefaultSpy,
+				stopPropagation: stopPropagationSpy
+			} );
+
+			sinon.assert.calledOnce( preventDefaultSpy );
+			sinon.assert.calledOnce( stopPropagationSpy );
+		} );
+
+		it( 'should focus the the #actionsView on `Tab` key press when #actionsView is visible', () => {
+			const keyEvtData = {
+				keyCode: keyCodes.tab,
+				preventDefault: sinon.spy(),
+				stopPropagation: sinon.spy()
+			};
+
+			const normalPriorityTabCallbackSpy = sinon.spy();
+			const highestPriorityTabCallbackSpy = sinon.spy();
+			editor.keystrokes.set( 'Tab', normalPriorityTabCallbackSpy );
+			editor.keystrokes.set( 'Tab', highestPriorityTabCallbackSpy, { priority: 'highest' } );
+
+			// Balloon is invisible, form not focused.
+			actionsView.focusTracker.isFocused = false;
+
+			const spy = sinon.spy( actionsView, 'focus' );
+
+			editor.keystrokes.press( keyEvtData );
+			sinon.assert.notCalled( keyEvtData.preventDefault );
+			sinon.assert.notCalled( keyEvtData.stopPropagation );
+			sinon.assert.notCalled( spy );
+			sinon.assert.calledOnce( normalPriorityTabCallbackSpy );
+			sinon.assert.calledOnce( highestPriorityTabCallbackSpy );
+
+			// Balloon is visible, form focused.
+			linkUIFeature._showUI();
+			testUtils.sinon.stub( linkUIFeature, '_areActionsVisible' ).value( true );
+
+			actionsView.focusTracker.isFocused = true;
+
+			editor.keystrokes.press( keyEvtData );
+			sinon.assert.notCalled( keyEvtData.preventDefault );
+			sinon.assert.notCalled( keyEvtData.stopPropagation );
+			sinon.assert.notCalled( spy );
+			sinon.assert.calledTwice( normalPriorityTabCallbackSpy );
+			sinon.assert.calledTwice( highestPriorityTabCallbackSpy );
+
+			// Balloon is still visible, form not focused.
+			actionsView.focusTracker.isFocused = false;
+
+			editor.keystrokes.press( keyEvtData );
+			sinon.assert.calledOnce( keyEvtData.preventDefault );
+			sinon.assert.calledOnce( keyEvtData.stopPropagation );
+			sinon.assert.calledOnce( spy );
+			sinon.assert.calledTwice( normalPriorityTabCallbackSpy );
+			sinon.assert.calledThrice( highestPriorityTabCallbackSpy );
+		} );
+
+		it( 'should hide the UI after Esc key press (from editor) and not focus the editable', () => {
+			const spy = testUtils.sinon.spy( linkUIFeature, '_hideUI' );
+			const keyEvtData = {
+				keyCode: keyCodes.esc,
+				preventDefault: sinon.spy(),
+				stopPropagation: sinon.spy()
+			};
+
+			// Balloon is visible.
+			linkUIFeature._showUI();
+			editor.keystrokes.press( keyEvtData );
+
+			sinon.assert.calledWithExactly( spy );
+		} );
+
+		it( 'should not hide the UI after Esc key press (from editor) when UI is open but is not visible', () => {
+			const spy = testUtils.sinon.spy( linkUIFeature, '_hideUI' );
+			const keyEvtData = {
+				keyCode: keyCodes.esc,
+				preventDefault: () => {},
+				stopPropagation: () => {}
+			};
+
+			const viewMock = {
+				ready: true,
+				render: () => {},
+				destroy: () => {}
+			};
+
+			linkUIFeature._showUI();
+
+			// Some view precedes the link UI in the balloon.
+			balloon.add( { view: viewMock } );
+			editor.keystrokes.press( keyEvtData );
+
+			sinon.assert.notCalled( spy );
+		} );
+	} );
+
+	describe( 'mouse support', () => {
+		it( 'should hide the UI and not focus editable upon clicking outside the UI', () => {
+			const spy = testUtils.sinon.spy( linkUIFeature, '_hideUI' );
+
+			linkUIFeature._showUI( true );
+			document.body.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) );
+
+			sinon.assert.calledWithExactly( spy );
+		} );
+
+		it( 'should not hide the UI upon clicking inside the the UI', () => {
+			const spy = testUtils.sinon.spy( linkUIFeature, '_hideUI' );
+
+			linkUIFeature._showUI( true );
+			balloon.view.element.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) );
+
+			sinon.assert.notCalled( spy );
+		} );
+
+		describe( 'clicking on editable', () => {
+			let observer, spy;
+
+			beforeEach( () => {
+				observer = editor.editing.view.getObserver( ClickObserver );
+				editor.model.schema.extend( '$text', { allowIn: '$root' } );
+
+				spy = testUtils.sinon.stub( linkUIFeature, '_showUI' ).returns( {} );
+			} );
+
+			it( 'should show the UI when collapsed selection is inside link element', () => {
+				setModelData( editor.model, '<$text linkHref="url">fo[]o</$text>' );
+
+				observer.fire( 'click', { target: document.body } );
+				sinon.assert.calledWithExactly( spy );
+			} );
+
+			it( 'should show the UI when selection exclusively encloses a link element (#1)', () => {
+				setModelData( editor.model, '[<$text linkHref="url">foo</$text>]' );
+
+				observer.fire( 'click', { target: {} } );
+				sinon.assert.calledWithExactly( spy );
+			} );
+
+			it( 'should show the UI when selection exclusively encloses a link element (#2)', () => {
+				setModelData( editor.model, '<$text linkHref="url">[foo]</$text>' );
+
+				observer.fire( 'click', { target: {} } );
+				sinon.assert.calledWithExactly( spy );
+			} );
+
+			it( 'should do nothing when selection is not inside link element', () => {
+				setModelData( editor.model, '[]' );
+
+				observer.fire( 'click', { target: {} } );
+				sinon.assert.notCalled( spy );
+			} );
+
+			it( 'should do nothing when selection is non-collapsed and doesn\'t enclose a link element (#1)', () => {
+				setModelData( editor.model, '<$text linkHref="url">f[o]o</$text>' );
+
+				observer.fire( 'click', { target: {} } );
+				sinon.assert.notCalled( spy );
+			} );
+
+			it( 'should do nothing when selection is non-collapsed and doesn\'t enclose a link element (#2)', () => {
+				setModelData( editor.model, '<$text linkHref="url">[fo]o</$text>' );
+
+				observer.fire( 'click', { target: {} } );
+				sinon.assert.notCalled( spy );
+			} );
+
+			it( 'should do nothing when selection is non-collapsed and doesn\'t enclose a link element (#3)', () => {
+				setModelData( editor.model, '<$text linkHref="url">f[oo]</$text>' );
+
+				observer.fire( 'click', { target: {} } );
+				sinon.assert.notCalled( spy );
+			} );
+
+			it( 'should do nothing when selection is non-collapsed and doesn\'t enclose a link element (#4)', () => {
+				setModelData( editor.model, 'ba[r<$text linkHref="url">foo]</$text>' );
+
+				observer.fire( 'click', { target: {} } );
+				sinon.assert.notCalled( spy );
+			} );
+
+			it( 'should do nothing when selection is non-collapsed and doesn\'t enclose a link element (#5)', () => {
+				setModelData( editor.model, 'ba[r<$text linkHref="url">foo</$text>]' );
+
+				observer.fire( 'click', { target: {} } );
+				sinon.assert.notCalled( spy );
+			} );
+		} );
+	} );
+
+	describe( 'actions view', () => {
+		let focusEditableSpy;
+
+		beforeEach( () => {
+			focusEditableSpy = testUtils.sinon.spy( editor.editing.view, 'focus' );
+		} );
+
+		it( 'should mark the editor UI as focused when the #actionsView is focused', () => {
+			linkUIFeature._showUI();
+			linkUIFeature._removeFormView();
+
+			expect( balloon.visibleView ).to.equal( actionsView );
+
+			editor.ui.focusTracker.isFocused = false;
+			actionsView.element.dispatchEvent( new Event( 'focus' ) );
+
+			expect( editor.ui.focusTracker.isFocused ).to.be.true;
+		} );
+
+		describe( 'binding', () => {
+			it( 'should show the #formView on #edit event and select the URL input field', () => {
+				linkUIFeature._showUI();
+				linkUIFeature._removeFormView();
+
+				const selectSpy = testUtils.sinon.spy( formView.urlInputView, 'select' );
+				actionsView.fire( 'edit' );
+
+				expect( balloon.visibleView ).to.equal( formView );
+				sinon.assert.calledOnce( selectSpy );
+			} );
+
+			it( 'should execute unlink command on actionsView#unlink event', () => {
+				const executeSpy = testUtils.sinon.spy( editor, 'execute' );
+
+				actionsView.fire( 'unlink' );
+
+				expect( executeSpy.calledOnce ).to.be.true;
+				expect( executeSpy.calledWithExactly( 'unlink' ) ).to.be.true;
+			} );
+
+			it( 'should hide and focus editable on actionsView#unlink event', () => {
+				linkUIFeature._showUI();
+				linkUIFeature._removeFormView();
+
+				// Removing the form would call the focus spy.
+				focusEditableSpy.resetHistory();
+				actionsView.fire( 'unlink' );
+
+				expect( balloon.visibleView ).to.be.null;
+				expect( focusEditableSpy.calledOnce ).to.be.true;
+			} );
+
+			it( 'should hide after Esc key press', () => {
+				const keyEvtData = {
+					keyCode: keyCodes.esc,
+					preventDefault: sinon.spy(),
+					stopPropagation: sinon.spy()
+				};
+
+				linkUIFeature._showUI();
+				linkUIFeature._removeFormView();
+
+				// Removing the form would call the focus spy.
+				focusEditableSpy.resetHistory();
+
+				actionsView.keystrokes.press( keyEvtData );
+				expect( balloon.visibleView ).to.equal( null );
+				expect( focusEditableSpy.calledOnce ).to.be.true;
+			} );
+		} );
+	} );
+
+	describe( 'link form view', () => {
+		let focusEditableSpy;
+
+		beforeEach( () => {
+			focusEditableSpy = testUtils.sinon.spy( editor.editing.view, 'focus' );
+		} );
+
+		it( 'should mark the editor UI as focused when the #formView is focused', () => {
+			linkUIFeature._showUI();
+			expect( balloon.visibleView ).to.equal( formView );
+
+			editor.ui.focusTracker.isFocused = false;
+			formView.element.dispatchEvent( new Event( 'focus' ) );
+
+			expect( editor.ui.focusTracker.isFocused ).to.be.true;
+		} );
+
+		describe( 'binding', () => {
+			beforeEach( () => {
+				setModelData( editor.model, '<paragraph>f[o]o</paragraph>' );
+			} );
+
+			it( 'should bind formView.urlInputView#value to link command value', () => {
+				const command = editor.commands.get( 'link' );
+
+				expect( formView.urlInputView.value ).to.undefined;
+
+				command.value = 'http://cksource.com';
+				expect( formView.urlInputView.value ).to.equal( 'http://cksource.com' );
+			} );
+
+			it( 'should execute link command on formView#submit event', () => {
+				const executeSpy = testUtils.sinon.spy( editor, 'execute' );
+
+				formView.urlInputView.value = 'http://ckeditor.com';
+				expect( formView.urlInputView.inputView.element.value ).to.equal( 'http://ckeditor.com' );
+
+				formView.urlInputView.inputView.element.value = 'http://cksource.com';
+				formView.fire( 'submit' );
+
+				expect( executeSpy.calledOnce ).to.be.true;
+				expect( executeSpy.calledWithExactly( 'link', 'http://cksource.com' ) ).to.be.true;
+			} );
+
+			it( 'should hide and reveal the #actionsView on formView#submit event', () => {
+				linkUIFeature._showUI();
+				formView.fire( 'submit' );
+
+				expect( balloon.visibleView ).to.equal( actionsView );
+				expect( focusEditableSpy.calledOnce ).to.be.true;
+			} );
+
+			it( 'should hide and reveal the #actionsView on formView#cancel event', () => {
+				linkUIFeature._showUI();
+				formView.fire( 'cancel' );
+
+				expect( balloon.visibleView ).to.equal( actionsView );
+				expect( focusEditableSpy.calledOnce ).to.be.true;
+			} );
+
+			it( 'should hide after Esc key press', () => {
+				const keyEvtData = {
+					keyCode: keyCodes.esc,
+					preventDefault: sinon.spy(),
+					stopPropagation: sinon.spy()
+				};
+
+				linkUIFeature._showUI();
+
+				formView.keystrokes.press( keyEvtData );
+				expect( balloon.visibleView ).to.equal( actionsView );
+				expect( focusEditableSpy.calledOnce ).to.be.true;
+			} );
+		} );
+	} );
+} );