Bladeren bron

Feature: Integrated Link plugin with ContextualBalloon plugin. Closes #91.

Oskar Wróbel 8 jaren geleden
bovenliggende
commit
e66d077673
2 gewijzigde bestanden met toevoegingen van 245 en 180 verwijderingen
  1. 119 107
      packages/ckeditor5-link/src/link.js
  2. 126 73
      packages/ckeditor5-link/tests/link.js

+ 119 - 107
packages/ckeditor5-link/src/link.js

@@ -11,12 +11,11 @@ import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
 import ClickObserver from '@ckeditor/ckeditor5-engine/src/view/observer/clickobserver';
 import LinkEngine from './linkengine';
 import LinkElement from './linkelement';
+import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/contextualballoon';
 
 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';
@@ -25,9 +24,10 @@ import unlinkIcon from '../theme/icons/unlink.svg';
 import '../theme/theme.scss';
 
 /**
- * The link feature. It introduces the Link and Unlink buttons and the <kbd>Ctrl+K</kbd> keystroke.
+ * 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 feature}.
+ * It uses the {@link module:link/linkengine~LinkEngine link engine plugin} and the
+ * {@link module:ui/contextualballoon~ContextualBalloon contextual balloon plugin}.
  *
  * @extends module:core/plugin~Plugin
  */
@@ -36,7 +36,7 @@ export default class Link extends Plugin {
 	 * @inheritDoc
 	 */
 	static get requires() {
-		return [ LinkEngine ];
+		return [ LinkEngine, ContextualBalloon ];
 	}
 
 	/**
@@ -53,27 +53,67 @@ export default class Link extends Plugin {
 		this.editor.editing.view.addObserver( ClickObserver );
 
 		/**
-		 * Balloon panel view to display the main UI.
+		 * The form view displayed inside of the balloon.
 		 *
-		 * @member {module:link/ui/balloonpanel~BalloonPanelView}
+		 * @member {module:link/ui/linkformview~LinkFormView}
 		 */
-		this.balloonPanelView = this._createBalloonPanel();
+		this.formView = this._createForm();
 
 		/**
-		 * The form view inside {@link #balloonPanelView}.
+		 * The contextual balloon plugin instance.
 		 *
-		 * @member {module:link/ui/linkformview~LinkFormView}
+		 * @private
+		 * @member {module:ui/contextualballoon~ContextualBalloon}
 		 */
-		this.formView = this._createForm();
+		this._balloon = this.editor.plugins.get( ContextualBalloon );
 
 		// Create toolbar buttons.
 		this._createToolbarLinkButton();
 		this._createToolbarUnlinkButton();
+
+		// Attach lifecycle actions to the the balloon.
+		this._attachActions();
+	}
+
+	/**
+	 * 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 the 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;
 	}
 
 	/**
 	 * Creates a toolbar link button. Clicking this button will show
-	 * {@link #balloonPanelView} attached to the selection.
+	 * {@link #_balloon} attached to the selection.
 	 *
 	 * @private
 	 */
@@ -82,8 +122,8 @@ export default class Link extends Plugin {
 		const linkCommand = editor.commands.get( 'link' );
 		const t = editor.t;
 
-		// Handle `Ctrl+K` keystroke and show panel.
-		editor.keystrokes.set( 'CTRL+K', () => this._showPanel() );
+		// Handle `Ctrl+K` keystroke and show the panel.
+		editor.keystrokes.set( 'CTRL+K', () => this._showPanel( true ) );
 
 		editor.ui.componentFactory.add( 'link', ( locale ) => {
 			const button = new ButtonView( locale );
@@ -98,7 +138,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 +174,13 @@ export default class Link extends Plugin {
 	}
 
 	/**
-	 * Creates the {@link module:ui/panel/balloon/balloonpanelview~BalloonPanelView} instance.
+	 * Attaches actions which control whether the balloon panel containing the
+	 * {@link #formView} is visible or not.
 	 *
 	 * @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;
 
 		// 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 +188,87 @@ 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();
 
+				// Avoid duplication of the same listener.
+				this.stopListening( viewDocument, 'render' );
+
+				// 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 );
+						this._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 the balloon is visible and the Tab key has been pressed.
+		this.editor.keystrokes.set( 'Tab', ( data, cancel ) => {
+			if ( this._balloon.visibleView === this.formView && !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 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._balloon.visibleView === this.formView ) {
+				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._balloon.hasView( this.formView ),
+			contextElement: this._balloon.view.element,
 			callback: () => this._hidePanel()
 		} );
-
-		editor.ui.view.body.add( balloonPanelView );
-
-		return balloonPanelView;
 	}
 
 	/**
-	 * Creates the {@link module:link/ui/linkformview~LinkFormView} instance.
+	 * Adds the {@link #formView} to the {@link #_balloon}.
 	 *
 	 * @private
-	 * @returns {module:link/ui/linkformview~LinkFormView} Link form instance.
+	 * @param {Boolean} [focusInput=false] When `true`, 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._balloon.hasView( this.formView ) ) {
+			return;
+		}
 
-		// Close the panel on esc key press when the form has focus.
-		formView.keystrokes.set( 'Esc', ( data, cancel ) => {
-			this._hidePanel( true );
-			cancel();
+		this._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 the {@link #formView} from the {@link #_balloon}.
 	 *
 	 * @private
-	 * @param {Boolean} [focusEditable=false] When `true` then editable focus will be restored on panel hide.
+	 * @param {Boolean} [focusEditable=false] When `true`, editable focus will be restored on panel hide.
 	 */
 	_hidePanel( focusEditable ) {
-		this.balloonPanelView.hide();
+		if ( !this._balloon.hasView( this.formView ) ) {
+			return;
+		}
+
+		this._balloon.remove( this.formView );
+		this.stopListening( this.editor.editing.view, 'render' );
 
 		if ( focusEditable ) {
 			this.editor.editing.view.focus();
@@ -281,13 +276,30 @@ export default class Link extends Plugin {
 	}
 
 	/**
-	 * Shows {@link #balloonPanelView balloon panel view}.
+	 * Returns positioning options for the {@link #_balloon}. They control the way balloon is attached
+	 * to the target element or selection.
+	 *
+	 * If the selection is collapsed and inside a link element, then 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}
 	 */
-	_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 )
+		};
 	}
 }
 

+ 126 - 73
packages/ckeditor5-link/tests/link.js

@@ -12,8 +12,8 @@ import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-util
 
 import Link from '../src/link';
 import LinkEngine from '../src/linkengine';
+import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/contextualballoon';
 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 +21,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 +38,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.plugins.get( ContextualBalloon );
 			formView = linkFeature.formView;
+
+			// There is no point to execute `BalloonPanelView#attachTo` so override it.
+			testUtils.sinon.stub( balloon.view, 'attachTo', () => {} );
 		} );
 	} );
 
@@ -55,6 +58,10 @@ describe( 'Link', () => {
 		expect( editor.plugins.get( LinkEngine ) ).to.instanceOf( LinkEngine );
 	} );
 
+	it( 'should load ContextualBalloon', () => {
+		expect( editor.plugins.get( ContextualBalloon ) ).to.instanceOf( ContextualBalloon );
+	} );
+
 	it( 'should register click observer', () => {
 		expect( editor.editing.view.getObserver( ClickObserver ) ).to.instanceOf( ClickObserver );
 	} );
@@ -74,15 +81,15 @@ describe( 'Link', () => {
 			expect( linkButton.isEnabled ).to.be.false;
 		} );
 
-		it( 'should open panel on linkButtonView execute event', () => {
+		it( 'should add link form to the ContextualBalloon on execute event', () => {
 			linkButton.fire( 'execute' );
 
-			expect( linkFeature.balloonPanelView.isVisible ).to.true;
+			expect( balloon.visibleView ).to.equal( 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 link form to the ContextualBalloon and attach balloon 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 +98,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 link form to the ContextualBalloon and attach balloon to the selection, when selection is non-collapsed', () => {
 			editor.document.schema.allow( { name: '$text', inside: '$root' } );
 			setModelData( editor.document, 'so[me ur]l' );
 			editor.editing.view.isFocused = true;
@@ -108,13 +113,13 @@ 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
 			} ) );
 		} );
 
-		it( 'should select panel input value when panel is opened', () => {
+		it( 'should select link input value when link balloon is opened', () => {
 			const selectUrlInputSpy = testUtils.sinon.spy( linkFeature.formView.urlInputView, 'select' );
 
 			editor.editing.view.isFocused = true;
@@ -150,37 +155,34 @@ describe( 'Link', () => {
 		} );
 	} );
 
-	describe( 'link balloon panel', () => {
-		let hidePanelSpy, focusEditableSpy;
+	describe( 'ContextualBalloon', () => {
+		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 ContextualBalloon at default', () => {
+			expect( balloon.visibleView ).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 ContextualBalloon and form should be selected 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( balloon.visibleView ).to.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 not add panel to ContextualBalloon 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 +192,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 +202,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 +211,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 +222,80 @@ 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.visibleView ).to.null;
 					sinon.assert.notCalled( focusEditableSpy );
+				} );
 
-					balloonPanelView.isVisible = true;
+				it( 'should not close after Esc key press (from editor) when panel is in stack but not visible', () => {
+					const keyEvtData = {
+						keyCode: keyCodes.esc,
+						preventDefault: () => {},
+						stopPropagation: () => {}
+					};
+
+					const viewMock = {
+						destroy: () => {}
+					};
+
+					balloon.add( { view: formView } );
+					balloon.add( { view: viewMock } );
 
 					editor.keystrokes.press( keyEvtData );
 
-					sinon.assert.calledOnce( hidePanelSpy );
-					sinon.assert.calledOnce( focusEditableSpy );
+					expect( balloon.visibleView ).to.equal( viewMock );
+					expect( balloon.hasView( formView ) ).to.true;
+					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.add( { view: formView } );
+
 					formView.keystrokes.press( keyEvtData );
 
-					sinon.assert.calledOnce( hidePanelSpy );
+					expect( balloon.visibleView ).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.visibleView ).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.visibleView ).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 +303,7 @@ describe( 'Link', () => {
 
 				observer.fire( 'click', { target: document.body } );
 
-				expect( balloonPanelView.isVisible ).to.true;
+				expect( balloon.visibleView ).to.equal( formView );
 				expect( selectUrlInputSpy.notCalled ).to.true;
 			} );
 
@@ -301,15 +318,43 @@ describe( 'Link', () => {
 
 				observer.fire( 'click', { target: document.body } );
 
-				expect( balloonPanelView.isVisible ).to.true;
+				expect( balloon.visibleView ).to.equal( formView );
+
+				// 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();
+
+				// Check if balloon is still open and position was updated.
+				expect( balloon.visibleView ).to.equal( formView );
+				expect( balloon.view.attachTo.calledOnce ).to.true;
+			} );
+
+			it( 'should not duplicate `render` listener on `ViewDocument`', () => {
+				const observer = editor.editing.view.getObserver( ClickObserver );
+				const updatePositionSpy = testUtils.sinon.spy( balloon, 'updatePosition' );
+
+				editor.document.schema.allow( { name: '$text', inside: '$root' } );
+				setModelData( editor.document, '<$text linkHref="url">b[]ar</$text>' );
+
+				// Click at the same link more than once.
+				observer.fire( 'click', { target: document.body } );
+				observer.fire( 'click', { target: document.body } );
+				observer.fire( 'click', { target: document.body } );
+
+				sinon.assert.notCalled( updatePositionSpy );
 
-				const attachToSpy = testUtils.sinon.spy( balloonPanelView, 'attachTo' );
+				const root = editor.editing.view.getRoot();
+				const text = root.getChild( 0 ).getChild( 0 );
 
+				// 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;
+				// Position should be updated only once.
+				sinon.assert.calledOnce( updatePositionSpy );
 			} );
 
 			it( 'should close when selection goes outside the link element', () => {
@@ -323,12 +368,12 @@ describe( 'Link', () => {
 
 				observer.fire( 'click', { target: document.body } );
 
-				expect( balloonPanelView.isVisible ).to.true;
+				expect( balloon.visibleView ).to.equal( formView );
 
 				editor.editing.view.selection.setRanges( [ Range.createFromParentsAndOffsets( text, 3, text, 3 ) ], true );
 				editor.editing.view.render();
 
-				expect( balloonPanelView.isVisible ).to.false;
+				expect( balloon.visibleView ).to.null;
 			} );
 
 			it( 'should close when selection goes to the other link element with the same href', () => {
@@ -342,12 +387,12 @@ describe( 'Link', () => {
 
 				observer.fire( 'click', { target: document.body } );
 
-				expect( balloonPanelView.isVisible ).to.true;
+				expect( balloon.visibleView ).to.equal( formView );
 
 				editor.editing.view.selection.setRanges( [ Range.createFromParentsAndOffsets( text, 1, text, 1 ) ], true );
 				editor.editing.view.render();
 
-				expect( balloonPanelView.isVisible ).to.false;
+				expect( balloon.visibleView ).to.null;
 			} );
 
 			it( 'should close when selection becomes non-collapsed', () => {
@@ -361,12 +406,12 @@ describe( 'Link', () => {
 
 				observer.fire( 'click', { target: {} } );
 
-				expect( balloonPanelView.isVisible ).to.true;
+				expect( balloon.visibleView ).to.equal( formView );
 
 				editor.editing.view.selection.setRanges( [ Range.createFromParentsAndOffsets( text, 1, text, 2 ) ] );
 				editor.editing.view.render();
 
-				expect( balloonPanelView.isVisible ).to.false;
+				expect( balloon.visibleView ).to.null;
 			} );
 
 			it( 'should stop updating position after close', () => {
@@ -380,16 +425,19 @@ describe( 'Link', () => {
 
 				observer.fire( 'click', { target: {} } );
 
-				expect( balloonPanelView.isVisible ).to.true;
+				expect( balloon.visibleView ).to.equal( formView );
 
-				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 +447,7 @@ describe( 'Link', () => {
 
 				observer.fire( 'click', { target: {} } );
 
-				expect( balloonPanelView.isVisible ).to.false;
+				expect( balloon.visibleView ).to.null;
 			} );
 
 			it( 'should not open when selection is non-collapsed', () => {
@@ -410,16 +458,15 @@ describe( 'Link', () => {
 
 				observer.fire( 'click', { target: document.body } );
 
-				expect( balloonPanelView.isVisible ).to.false;
+				expect( balloon.visibleView ).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 +494,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.visibleView ).to.null;
 				expect( focusEditableSpy.calledOnce ).to.true;
 			} );
 
@@ -463,16 +512,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.visibleView ).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.visibleView ).to.null;
 				expect( focusEditableSpy.calledOnce ).to.true;
 			} );
 		} );