Преглед изворни кода

Integrated link UI with editor ContextualBalloon.

Oskar Wróbel пре 8 година
родитељ
комит
8eb70e4f03
2 измењених фајлова са 225 додато и 174 уклоњено
  1. 123 102
      packages/ckeditor5-link/src/link.js
  2. 102 72
      packages/ckeditor5-link/tests/link.js

+ 123 - 102
packages/ckeditor5-link/src/link.js

@@ -15,8 +15,6 @@ import LinkElement from './linkelement';
 import clickOutsideHandler from '@ckeditor/ckeditor5-ui/src/bindings/clickoutsidehandler';
 
 import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
-import BalloonPanelView from '@ckeditor/ckeditor5-ui/src/panel/balloon/balloonpanelview';
-
 import LinkFormView from './ui/linkformview';
 
 import linkIcon from '../theme/icons/link.svg';
@@ -26,6 +24,7 @@ import '../theme/theme.scss';
 
 /**
  * The link feature. It introduces the Link and Unlink buttons and the <kbd>Ctrl+K</kbd> keystroke.
+ * Link UI is displayed using {@link module:core/editor/editorui~EditorUI#balloon}.
  *
  * It uses the {@link module:link/linkengine~LinkEngine link engine feature}.
  *
@@ -50,15 +49,9 @@ export default class Link extends Plugin {
 	 * @inheritDoc
 	 */
 	init() {
+		// Register click observer to handle `click` event on the view document.
 		this.editor.editing.view.addObserver( ClickObserver );
 
-		/**
-		 * Balloon panel view to display the main UI.
-		 *
-		 * @member {module:link/ui/balloonpanel~BalloonPanelView}
-		 */
-		this.balloonPanelView = this._createBalloonPanel();
-
 		/**
 		 * The form view inside {@link #balloonPanelView}.
 		 *
@@ -69,6 +62,67 @@ export default class Link extends Plugin {
 		// Create toolbar buttons.
 		this._createToolbarLinkButton();
 		this._createToolbarUnlinkButton();
+
+		// Attach lifecycle actions to the link balloon.
+		this._attachActions();
+	}
+
+	/**
+	 * Returns `true` when link panel is added to the {@link module:core/editor/editorui~EditorUI#balloon} stack.
+	 *
+	 * @private
+	 * @returns {Boolean}
+	 */
+	get _isInStack() {
+		return this.editor.ui.balloon.isPanelInStack( this.formView );
+	}
+
+	/**
+	 * Returns `true` when link panel is currently visible in {@link module:core/editor/editorui~EditorUI#balloon}.
+	 *
+	 * @private
+	 * @returns {Boolean}
+	 */
+	get _isVisible() {
+		const balloon = this.editor.ui.balloon;
+
+		return balloon.visible && balloon.visible.view === this.formView;
+	}
+
+	/**
+	 * Creates the {@link module:link/ui/linkformview~LinkFormView} instance.
+	 *
+	 * @private
+	 * @returns {module:link/ui/linkformview~LinkFormView} Link form instance.
+	 */
+	_createForm() {
+		const editor = this.editor;
+		const formView = new LinkFormView( editor.locale );
+
+		formView.urlInputView.bind( 'value' ).to( editor.commands.get( 'link' ), 'value' );
+
+		// Execute link command after clicking on formView `Save` button.
+		this.listenTo( formView, 'submit', () => {
+			editor.execute( 'link', formView.urlInputView.inputView.element.value );
+			this._hidePanel( true );
+		} );
+
+		// Execute unlink command after clicking on formView `Unlink` button.
+		this.listenTo( formView, 'unlink', () => {
+			editor.execute( 'unlink' );
+			this._hidePanel( true );
+		} );
+
+		// Hide balloon panel after clicking on formView `Cancel` button.
+		this.listenTo( formView, 'cancel', () => this._hidePanel( true ) );
+
+		// Close the panel on esc key press when the form has focus.
+		formView.keystrokes.set( 'Esc', ( data, cancel ) => {
+			this._hidePanel( true );
+			cancel();
+		} );
+
+		return formView;
 	}
 
 	/**
@@ -83,7 +137,7 @@ export default class Link extends Plugin {
 		const t = editor.t;
 
 		// Handle `Ctrl+K` keystroke and show panel.
-		editor.keystrokes.set( 'CTRL+K', () => this._showPanel() );
+		editor.keystrokes.set( 'CTRL+K', () => this._showPanel( true ) );
 
 		editor.ui.componentFactory.add( 'link', ( locale ) => {
 			const button = new ButtonView( locale );
@@ -98,7 +152,7 @@ export default class Link extends Plugin {
 			button.bind( 'isEnabled' ).to( linkCommand, 'isEnabled' );
 
 			// Show the panel on button click.
-			this.listenTo( button, 'execute', () => this._showPanel() );
+			this.listenTo( button, 'execute', () => this._showPanel( true ) );
 
 			return button;
 		} );
@@ -134,22 +188,13 @@ export default class Link extends Plugin {
 	}
 
 	/**
-	 * Creates the {@link module:ui/panel/balloon/balloonpanelview~BalloonPanelView} instance.
+	 * Attaches actions which defines when panel should be open or close.
 	 *
 	 * @private
-	 * @returns {module:ui/panel/balloon/balloonpanelview~BalloonPanelView} Link balloon panel instance.
 	 */
-	_createBalloonPanel() {
-		const editor = this.editor;
-		const viewDocument = editor.editing.view;
-
-		// Create the balloon panel instance.
-		const balloonPanelView = new BalloonPanelView( editor.locale );
-		balloonPanelView.maxWidth = 300;
-
-		// Add balloonPanel.view#element to FocusTracker.
-		// @TODO: Do it automatically ckeditor5-core#23
-		editor.ui.focusTracker.add( balloonPanelView.element );
+	_attachActions() {
+		const viewDocument = this.editor.editing.view;
+		const balloon = this.editor.ui.balloon;
 
 		// 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.
@@ -157,123 +202,84 @@ export default class Link extends Plugin {
 			const viewSelection = viewDocument.selection;
 			const parentLink = getPositionParentLink( viewSelection.getFirstPosition() );
 
+			// When collapsed selection is inside link element (link element is clicked).
 			if ( viewSelection.isCollapsed && parentLink ) {
-				this._attachPanelToElement();
+				// Then show panel but keep focus inside editor editable.
+				this._showPanel();
 
+				// Start listen to view document changes and close the panel when selection will be moved
+				// out of the actual link element.
 				this.listenTo( viewDocument, 'render', () => {
 					const currentParentLink = getPositionParentLink( viewSelection.getFirstPosition() );
 
 					if ( !viewSelection.isCollapsed || parentLink !== currentParentLink ) {
 						this._hidePanel();
 					} else {
-						this._attachPanelToElement( parentLink );
+						balloon.updatePosition();
 					}
 				} );
-
-				this.listenTo( balloonPanelView, 'change:isVisible', () => this.stopListening( viewDocument, 'render' ) );
 			}
 		} );
 
-		// Focus the form if balloon panel is open and tab key has been pressed.
-		editor.keystrokes.set( 'Tab', ( data, cancel ) => {
-			if ( balloonPanelView.isVisible && !this.formView.focusTracker.isFocused ) {
+		// Focus the form if link panel is visible and tab key has been pressed.
+		this.editor.keystrokes.set( 'Tab', ( data, cancel ) => {
+			if ( this._isVisible && !this.formView.focusTracker.isFocused ) {
 				this.formView.focus();
 				cancel();
 			}
 		} );
 
-		// Close the panel on esc key press when editable has focus.
-		editor.keystrokes.set( 'Esc', ( data, cancel ) => {
-			if ( balloonPanelView.isVisible ) {
-				this._hidePanel( true );
+		// Close on `Esc` press when the editor is focused and link balloon is currently visible.
+		this.editor.keystrokes.set( 'Esc', ( data, cancel ) => {
+			if ( this._isVisible ) {
+				this._hidePanel();
 				cancel();
 			}
 		} );
 
 		// Close on click outside of balloon panel element.
 		clickOutsideHandler( {
-			emitter: balloonPanelView,
-			activator: () => balloonPanelView.isVisible,
-			contextElement: balloonPanelView.element,
+			emitter: this.formView,
+			activator: () => this._isInStack,
+			contextElement: balloon.view.element,
 			callback: () => this._hidePanel()
 		} );
-
-		editor.ui.view.body.add( balloonPanelView );
-
-		return balloonPanelView;
 	}
 
 	/**
-	 * Creates the {@link module:link/ui/linkformview~LinkFormView} instance.
+	 * Adds panel to {@link: core/editor/editorui~EditorUI#balloon}.
 	 *
 	 * @private
-	 * @returns {module:link/ui/linkformview~LinkFormView} Link form instance.
+	 * @param {Boolean} [focusInput=false] When `true` then link form will be focused on panel show.
 	 */
-	_createForm() {
-		const editor = this.editor;
-		const formView = new LinkFormView( editor.locale );
-
-		formView.urlInputView.bind( 'value' ).to( editor.commands.get( 'link' ), 'value' );
-
-		// Execute link command after clicking on formView `Save` button.
-		this.listenTo( formView, 'submit', () => {
-			editor.execute( 'link', formView.urlInputView.inputView.element.value );
-			this._hidePanel( true );
-		} );
-
-		// Execute unlink command after clicking on formView `Unlink` button.
-		this.listenTo( formView, 'unlink', () => {
-			editor.execute( 'unlink' );
-			this._hidePanel( true );
-		} );
+	_showPanel( focusInput ) {
+		if ( this._isInStack ) {
+			return;
+		}
 
-		// Close the panel on esc key press when the form has focus.
-		formView.keystrokes.set( 'Esc', ( data, cancel ) => {
-			this._hidePanel( true );
-			cancel();
+		this.editor.ui.balloon.add( {
+			view: this.formView,
+			position: this._getBalloonPositionData()
 		} );
 
-		// Hide balloon panel after clicking on formView `Cancel` button.
-		this.listenTo( formView, 'cancel', () => this._hidePanel( true ) );
-
-		this.balloonPanelView.content.add( formView );
-
-		return formView;
-	}
-
-	/**
-	 * Shows {@link #balloonPanelView link balloon panel} and attach to target element.
-	 * If selection is collapsed and is placed inside link element, then panel will be attached
-	 * to whole link element, otherwise will be attached to the selection.
-	 *
-	 * @private
-	 * @param {module:link/linkelement~LinkElement} [parentLink] Target element.
-	 */
-	_attachPanelToElement( parentLink ) {
-		const viewDocument = this.editor.editing.view;
-		const targetLink = parentLink || getPositionParentLink( viewDocument.selection.getFirstPosition() );
-
-		const target = targetLink ?
-				// When selection is inside link element, then attach panel to this element.
-				viewDocument.domConverter.getCorrespondingDomElement( targetLink )
-			:
-				// Otherwise attach panel to the selection.
-				viewDocument.domConverter.viewRangeToDom( viewDocument.selection.getFirstRange() );
-
-		this.balloonPanelView.attachTo( {
-			target,
-			limiter: viewDocument.domConverter.getCorrespondingDomElement( viewDocument.selection.editableElement )
-		} );
+		if ( focusInput ) {
+			this.formView.urlInputView.select();
+		}
 	}
 
 	/**
-	 * Hides {@link #balloonPanelView balloon panel view}.
+	 * Removes panel from {@link: core/editor/editorui~EditorUI#balloon}.
 	 *
 	 * @private
 	 * @param {Boolean} [focusEditable=false] When `true` then editable focus will be restored on panel hide.
 	 */
 	_hidePanel( focusEditable ) {
-		this.balloonPanelView.hide();
+		if ( !this._isInStack ) {
+			return;
+		}
+
+		this.editor.ui.balloon.remove( this.formView );
+		this.stopListening( this.editor.editing.view, 'render' );
 
 		if ( focusEditable ) {
 			this.editor.editing.view.focus();
@@ -281,13 +287,28 @@ export default class Link extends Plugin {
 	}
 
 	/**
-	 * Shows {@link #balloonPanelView balloon panel view}.
+	 * Returns position configuration for the balloon panel. According to this data balloon will be attached
+	 * to the target element. If selection is collapsed and is placed inside link element, then panel
+	 * will be attached to whole link element, otherwise will be attached to the selection.
 	 *
 	 * @private
+	 * @returns {module:utils/dom/position~Options}
 	 */
-	_showPanel() {
-		this._attachPanelToElement();
-		this.formView.urlInputView.select();
+	_getBalloonPositionData() {
+		const viewDocument = this.editor.editing.view;
+		const targetLink = getPositionParentLink( viewDocument.selection.getFirstPosition() );
+
+		const target = targetLink ?
+			// When selection is inside link element, then attach panel to this element.
+			viewDocument.domConverter.getCorrespondingDomElement( targetLink )
+			:
+			// Otherwise attach panel to the selection.
+			viewDocument.domConverter.viewRangeToDom( viewDocument.selection.getFirstRange() );
+
+		return {
+			target,
+			limiter: viewDocument.domConverter.getCorrespondingDomElement( viewDocument.selection.editableElement )
+		};
 	}
 }
 

+ 102 - 72
packages/ckeditor5-link/tests/link.js

@@ -13,7 +13,6 @@ import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-util
 import Link from '../src/link';
 import LinkEngine from '../src/linkengine';
 import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
-import BalloonPanelView from '@ckeditor/ckeditor5-ui/src/panel/balloon/balloonpanelview';
 
 import Range from '@ckeditor/ckeditor5-engine/src/view/range';
 import ClickObserver from '@ckeditor/ckeditor5-engine/src/view/observer/clickobserver';
@@ -21,7 +20,7 @@ import ClickObserver from '@ckeditor/ckeditor5-engine/src/view/observer/clickobs
 testUtils.createSinonSandbox();
 
 describe( 'Link', () => {
-	let editor, linkFeature, linkButton, unlinkButton, balloonPanelView, formView, editorElement;
+	let editor, linkFeature, linkButton, unlinkButton, balloon, formView, editorElement;
 
 	beforeEach( () => {
 		editorElement = document.createElement( 'div' );
@@ -38,8 +37,11 @@ describe( 'Link', () => {
 			linkFeature = editor.plugins.get( Link );
 			linkButton = editor.ui.componentFactory.create( 'link' );
 			unlinkButton = editor.ui.componentFactory.create( 'unlink' );
-			balloonPanelView = linkFeature.balloonPanelView;
+			balloon = editor.ui.balloon;
 			formView = linkFeature.formView;
+
+			// There is no point to execute `BalloonPanelView#attachTo` so override it.
+			testUtils.sinon.stub( balloon.view, 'attachTo', () => {} );
 		} );
 	} );
 
@@ -74,15 +76,13 @@ describe( 'Link', () => {
 			expect( linkButton.isEnabled ).to.be.false;
 		} );
 
-		it( 'should open panel on linkButtonView execute event', () => {
+		it( 'should add panel to the `ui#balloon` on execute event', () => {
 			linkButton.fire( 'execute' );
 
-			expect( linkFeature.balloonPanelView.isVisible ).to.true;
+			expect( balloon.visible.view ).to.deep.equal( linkFeature.formView );
 		} );
 
-		it( 'should open panel attached to the link element, when collapsed selection is inside link element', () => {
-			const attachToSpy = testUtils.sinon.spy( balloonPanelView, 'attachTo' );
-
+		it( 'should add panel to `ui#balloon` attached to the link element, when collapsed selection is inside link element', () => {
 			editor.document.schema.allow( { name: '$text', inside: '$root' } );
 			setModelData( editor.document, '<$text linkHref="url">some[] url</$text>' );
 			editor.editing.view.isFocused = true;
@@ -91,15 +91,13 @@ describe( 'Link', () => {
 
 			const linkElement = editorElement.querySelector( 'a' );
 
-			sinon.assert.calledWithExactly( attachToSpy, sinon.match( {
+			sinon.assert.calledWithExactly( balloon.view.attachTo, sinon.match( {
 				target: linkElement,
 				limiter: editorElement
 			} ) );
 		} );
 
-		it( 'should open panel attached to the selection, when there is non-collapsed selection', () => {
-			const attachToSpy = testUtils.sinon.spy( balloonPanelView, 'attachTo' );
-
+		it( 'should add panel to `ui#balloon` attached to the selection, when there is non-collapsed selection', () => {
 			editor.document.schema.allow( { name: '$text', inside: '$root' } );
 			setModelData( editor.document, 'so[me ur]l' );
 			editor.editing.view.isFocused = true;
@@ -108,7 +106,7 @@ describe( 'Link', () => {
 
 			const selectedRange = editorElement.ownerDocument.getSelection().getRangeAt( 0 );
 
-			sinon.assert.calledWithExactly( attachToSpy, sinon.match( {
+			sinon.assert.calledWithExactly( balloon.view.attachTo, sinon.match( {
 				target: selectedRange,
 				limiter: editorElement
 			} ) );
@@ -150,37 +148,34 @@ describe( 'Link', () => {
 		} );
 	} );
 
-	describe( 'link balloon panel', () => {
-		let hidePanelSpy, focusEditableSpy;
+	describe( 'balloon panel', () => {
+		let focusEditableSpy;
 
 		beforeEach( () => {
-			hidePanelSpy = testUtils.sinon.spy( balloonPanelView, 'hide' );
 			focusEditableSpy = testUtils.sinon.spy( editor.editing.view, 'focus' );
 		} );
 
-		it( 'should be created', () => {
-			expect( balloonPanelView ).to.instanceOf( BalloonPanelView );
+		it( 'should not be added to `balloon#ui` at default', () => {
+			expect( editor.ui.balloon.visible ).to.null;
 		} );
 
-		it( 'should be appended to the document body', () => {
-			expect( document.body.contains( balloonPanelView.element ) );
-		} );
-
-		it( 'should open with selected url input on `CTRL+K` keystroke', () => {
-			const selectUrlInputSpy = testUtils.sinon.spy( linkFeature.formView.urlInputView, 'select' );
+		it( 'should be added to `balloon#ui` and focus the link form on `CTRL+K` keystroke', () => {
+			const selectUrlInputSpy = testUtils.sinon.spy( formView.urlInputView, 'select' );
 
 			editor.keystrokes.press( { keyCode: keyCodes.k, ctrlKey: true } );
 
-			expect( balloonPanelView.isVisible ).to.true;
+			expect( editor.ui.balloon.visible.view ).to.deep.equal( formView );
 			expect( selectUrlInputSpy.calledOnce ).to.true;
 		} );
 
-		it( 'should add balloon panel element to focus tracker', () => {
-			editor.ui.focusTracker.isFocused = false;
-
-			balloonPanelView.element.dispatchEvent( new Event( 'focus' ) );
+		it( 'should do nothing when panel is being added to `balloon#ui` more than once', () => {
+			// Add panel to balloon by pressing toolbar button.
+			linkButton.fire( 'execute' );
 
-			expect( editor.ui.focusTracker.isFocused ).to.true;
+			// Press button once again.
+			expect( () => {
+				linkButton.fire( 'execute' );
+			} ).to.not.throw();
 		} );
 
 		it( 'should focus the link form on Tab key press', () => {
@@ -190,8 +185,7 @@ describe( 'Link', () => {
 				stopPropagation: sinon.spy()
 			};
 
-			// Mock balloon invisible, form not focused.
-			balloonPanelView.isVisible = false;
+			// Balloon is invisible, form not focused.
 			formView.focusTracker.isFocused = false;
 
 			const spy = sinon.spy( formView, 'focus' );
@@ -201,8 +195,8 @@ describe( 'Link', () => {
 			sinon.assert.notCalled( keyEvtData.stopPropagation );
 			sinon.assert.notCalled( spy );
 
-			// Mock balloon visible, form focused.
-			balloonPanelView.isVisible = true;
+			// Balloon is visible, form focused.
+			balloon.add( { view: formView } );
 			formView.focusTracker.isFocused = true;
 
 			editor.keystrokes.press( keyEvtData );
@@ -210,8 +204,7 @@ describe( 'Link', () => {
 			sinon.assert.notCalled( keyEvtData.stopPropagation );
 			sinon.assert.notCalled( spy );
 
-			// Mock balloon visible, form not focused.
-			balloonPanelView.isVisible = true;
+			// Balloon is still visible, form not focused.
 			formView.focusTracker.isFocused = false;
 
 			editor.keystrokes.press( keyEvtData );
@@ -222,63 +215,89 @@ describe( 'Link', () => {
 
 		describe( 'close listeners', () => {
 			describe( 'keyboard', () => {
-				it( 'should close after Esc key press (from editor)', () => {
+				it( 'should close after Esc key press (from editor) and not focus editable', () => {
 					const keyEvtData = {
 						keyCode: keyCodes.esc,
 						preventDefault: sinon.spy(),
 						stopPropagation: sinon.spy()
 					};
 
-					balloonPanelView.isVisible = false;
+					// Balloon is visible.
+					balloon.add( { view: formView } );
 
 					editor.keystrokes.press( keyEvtData );
 
-					sinon.assert.notCalled( hidePanelSpy );
+					expect( balloon.visible ).to.null;
 					sinon.assert.notCalled( focusEditableSpy );
+				} );
 
-					balloonPanelView.isVisible = true;
+				it( 'should not close after Esc key press (from editor) when panel is not visible', () => {
+					const keyEvtData = {
+						keyCode: keyCodes.esc,
+						preventDefault: sinon.spy(),
+						stopPropagation: sinon.spy()
+					};
+
+					const viewMock = {
+						destroy: () => {}
+					};
+
+					// Balloon is visible.
+					balloon.add( { view: formView } );
+
+					// Balloon is not visible because other panel is added to the balloon stack.
+					balloon.add( { view: viewMock } );
 
 					editor.keystrokes.press( keyEvtData );
 
-					sinon.assert.calledOnce( hidePanelSpy );
-					sinon.assert.calledOnce( focusEditableSpy );
+					// Balloon is visible.
+					expect( balloon.visible.view ).to.equal( viewMock );
+
+					// Link panel is in balloon stack.
+					expect( balloon.isPanelInStack( formView ) ).to.true;
+
+					// Editable was not focused.
+					sinon.assert.notCalled( focusEditableSpy );
 				} );
 
-				it( 'should close after Esc key press (from the form)', () => {
+				it( 'should close after Esc key press (from the form) and focus editable', () => {
 					const keyEvtData = {
 						keyCode: keyCodes.esc,
 						preventDefault: sinon.spy(),
 						stopPropagation: sinon.spy()
 					};
 
+					// Balloon is panel.
+					balloon.add( { view: formView } );
+
 					formView.keystrokes.press( keyEvtData );
 
-					sinon.assert.calledOnce( hidePanelSpy );
+					expect( balloon.visible ).to.null;
 					sinon.assert.calledOnce( focusEditableSpy );
 				} );
 			} );
 
 			describe( 'mouse', () => {
 				it( 'should close and not focus editable on click outside the panel', () => {
-					balloonPanelView.isVisible = true;
+					balloon.add( { view: formView } );
 					document.body.dispatchEvent( new Event( 'mouseup', { bubbles: true } ) );
 
-					expect( hidePanelSpy.calledOnce ).to.true;
+					expect( balloon.visible ).to.null;
 					expect( focusEditableSpy.notCalled ).to.true;
 				} );
 
 				it( 'should not close on click inside the panel', () => {
-					balloonPanelView.isVisible = true;
-					balloonPanelView.element.dispatchEvent( new Event( 'mouseup', { bubbles: true } ) );
+					balloon.add( { view: formView } );
+					balloon.view.element.dispatchEvent( new Event( 'mouseup', { bubbles: true } ) );
 
-					expect( hidePanelSpy.notCalled ).to.true;
+					expect( balloon.visible.view ).to.equal( formView );
 				} );
 			} );
 		} );
 
 		describe( 'click on editable', () => {
 			it( 'should open with not selected url input when collapsed selection is inside link element', () => {
-				const selectUrlInputSpy = testUtils.sinon.spy( linkFeature.formView.urlInputView, 'select' );
+				const selectUrlInputSpy = testUtils.sinon.spy( formView.urlInputView, 'select' );
 				const observer = editor.editing.view.getObserver( ClickObserver );
 
 				editor.document.schema.allow( { name: '$text', inside: '$root' } );
@@ -286,7 +305,7 @@ describe( 'Link', () => {
 
 				observer.fire( 'click', { target: document.body } );
 
-				expect( balloonPanelView.isVisible ).to.true;
+				expect( balloon.visible.view === formView ).to.true;
 				expect( selectUrlInputSpy.notCalled ).to.true;
 			} );
 
@@ -301,15 +320,18 @@ describe( 'Link', () => {
 
 				observer.fire( 'click', { target: document.body } );
 
-				expect( balloonPanelView.isVisible ).to.true;
+				expect( balloon.visible.view === formView ).to.true;
 
-				const attachToSpy = testUtils.sinon.spy( balloonPanelView, 'attachTo' );
+				// Reset attachTo call counter.
+				balloon.view.attachTo.reset();
 
+				// Move selection.
 				editor.editing.view.selection.setRanges( [ Range.createFromParentsAndOffsets( text, 1, text, 1 ) ], true );
 				editor.editing.view.render();
 
-				expect( balloonPanelView.isVisible ).to.true;
-				expect( attachToSpy.calledOnce ).to.true;
+				// Check if balloon is still open and position was updated.
+				expect( balloon.visible.view === formView ).to.true;
+				expect( balloon.view.attachTo.calledOnce ).to.true;
 			} );
 
 			it( 'should close when selection goes outside the link element', () => {
@@ -323,12 +345,12 @@ describe( 'Link', () => {
 
 				observer.fire( 'click', { target: document.body } );
 
-				expect( balloonPanelView.isVisible ).to.true;
+				expect( balloon.visible.view === formView ).to.true;
 
 				editor.editing.view.selection.setRanges( [ Range.createFromParentsAndOffsets( text, 3, text, 3 ) ], true );
 				editor.editing.view.render();
 
-				expect( balloonPanelView.isVisible ).to.false;
+				expect( balloon.visible ).to.null;
 			} );
 
 			it( 'should close when selection goes to the other link element with the same href', () => {
@@ -342,12 +364,12 @@ describe( 'Link', () => {
 
 				observer.fire( 'click', { target: document.body } );
 
-				expect( balloonPanelView.isVisible ).to.true;
+				expect( balloon.visible.view === formView ).to.true;
 
 				editor.editing.view.selection.setRanges( [ Range.createFromParentsAndOffsets( text, 1, text, 1 ) ], true );
 				editor.editing.view.render();
 
-				expect( balloonPanelView.isVisible ).to.false;
+				expect( balloon.visible ).to.null;
 			} );
 
 			it( 'should close when selection becomes non-collapsed', () => {
@@ -361,12 +383,12 @@ describe( 'Link', () => {
 
 				observer.fire( 'click', { target: {} } );
 
-				expect( balloonPanelView.isVisible ).to.true;
+				expect( balloon.visible.view === formView ).to.true;
 
 				editor.editing.view.selection.setRanges( [ Range.createFromParentsAndOffsets( text, 1, text, 2 ) ] );
 				editor.editing.view.render();
 
-				expect( balloonPanelView.isVisible ).to.false;
+				expect( balloon.visible ).to.null;
 			} );
 
 			it( 'should stop updating position after close', () => {
@@ -380,16 +402,19 @@ describe( 'Link', () => {
 
 				observer.fire( 'click', { target: {} } );
 
-				expect( balloonPanelView.isVisible ).to.true;
+				expect( balloon.visible.view === formView ).to.true;
 
-				balloonPanelView.isVisible = false;
+				// Close balloon by dispatching `cancel` event on formView.
+				formView.fire( 'cancel' );
 
-				const attachToSpy = testUtils.sinon.spy( balloonPanelView, 'attachTo' );
+				// Reset attachTo call counter.
+				balloon.view.attachTo.reset();
 
+				// Move selection inside link element.
 				editor.editing.view.selection.setRanges( [ Range.createFromParentsAndOffsets( text, 2, text, 2 ) ], true );
 				editor.editing.view.render();
 
-				expect( attachToSpy.notCalled ).to.true;
+				expect( balloon.view.attachTo.notCalled ).to.true;
 			} );
 
 			it( 'should not open when selection is not inside link element', () => {
@@ -399,7 +424,7 @@ describe( 'Link', () => {
 
 				observer.fire( 'click', { target: {} } );
 
-				expect( balloonPanelView.isVisible ).to.false;
+				expect( balloon.visible ).to.null;
 			} );
 
 			it( 'should not open when selection is non-collapsed', () => {
@@ -410,16 +435,15 @@ describe( 'Link', () => {
 
 				observer.fire( 'click', { target: document.body } );
 
-				expect( balloonPanelView.isVisible ).to.false;
+				expect( balloon.visible ).to.null;
 			} );
 		} );
 	} );
 
 	describe( 'link form', () => {
-		let hidePanelSpy, focusEditableSpy;
+		let focusEditableSpy;
 
 		beforeEach( () => {
-			hidePanelSpy = testUtils.sinon.spy( balloonPanelView, 'hide' );
 			focusEditableSpy = testUtils.sinon.spy( editor.editing.view, 'focus' );
 		} );
 
@@ -447,9 +471,11 @@ describe( 'Link', () => {
 			} );
 
 			it( 'should hide and focus editable on formView#submit event', () => {
+				balloon.add( { view: formView } );
+
 				formView.fire( 'submit' );
 
-				expect( hidePanelSpy.calledOnce ).to.true;
+				expect( balloon.visible ).to.null;
 				expect( focusEditableSpy.calledOnce ).to.true;
 			} );
 
@@ -463,16 +489,20 @@ describe( 'Link', () => {
 			} );
 
 			it( 'should hide and focus editable on formView#unlink event', () => {
+				balloon.add( { view: formView } );
+
 				formView.fire( 'unlink' );
 
-				expect( hidePanelSpy.calledOnce ).to.true;
+				expect( balloon.visible ).to.null;
 				expect( focusEditableSpy.calledOnce ).to.true;
 			} );
 
 			it( 'should hide and focus editable on formView#cancel event', () => {
+				balloon.add( { view: formView } );
+
 				formView.fire( 'cancel' );
 
-				expect( hidePanelSpy.calledOnce ).to.true;
+				expect( balloon.visible ).to.null;
 				expect( focusEditableSpy.calledOnce ).to.true;
 			} );
 		} );