8
0
Quellcode durchsuchen

Merge branch 'master' into t/39

Piotrek Koszuliński vor 9 Jahren
Ursprung
Commit
24151c4d13

+ 100 - 70
packages/ckeditor5-link/src/link.js

@@ -16,6 +16,8 @@ import ButtonView from '../ui/button/buttonview.js';
 import LinkBalloonPanel from './ui/linkballoonpanel.js';
 import LinkBalloonPanelView from './ui/linkballoonpanelview.js';
 
+import { keyCodes } from '../utils/keyboard.js';
+
 /**
  * The link feature. It introduces the Link and Unlink buttons and the <kbd>Ctrl+L</kbd> keystroke.
  *
@@ -58,7 +60,6 @@ export default class Link extends Feature {
 	 */
 	_createToolbarLinkButton() {
 		const editor = this.editor;
-		const viewDocument = editor.editing.view;
 		const linkCommand = editor.commands.get( 'link' );
 		const t = editor.t;
 
@@ -76,11 +77,8 @@ export default class Link extends Feature {
 
 		// Show the panel on button click only when editor is focused.
 		this.listenTo( linkButtonModel, 'execute', () => {
-			if ( !viewDocument.isFocused ) {
-				return;
-			}
-
 			this._attachPanelToElement();
+			this.balloonPanel.urlInput.view.select();
 		} );
 
 		// Add link button to feature components.
@@ -88,7 +86,7 @@ export default class Link extends Feature {
 	}
 
 	/**
-	 * Create a toolbar unlink button. Clicking this button will unlink
+	 * Creates a toolbar unlink button. Clicking this button will unlink
 	 * the selected link.
 	 *
 	 * @private
@@ -112,10 +110,6 @@ export default class Link extends Feature {
 		// Execute unlink command and hide panel, if open.
 		this.listenTo( unlinkButtonModel, 'execute', () => {
 			editor.execute( 'unlink' );
-
-			if ( this.balloonPanel.view.isVisible ) {
-				this.balloonPanel.view.hide();
-			}
 		} );
 
 		// Add unlink button to feature components.
@@ -123,41 +117,10 @@ export default class Link extends Feature {
 	}
 
 	/**
-	 * Creates the {@link link.ui.LinkBalloonPanel LinkBalloonPanel} instance
-	 * and attaches link command to {@link link.LinkBalloonPanelModel#execute} event.
-	 *
-	 *	                       +------------------------------------+
-	 *	                       | <a href="http://foo.com">[foo]</a> |
-	 *	                       +------------------------------------+
-	 *	                                      Document
-	 *	             Value set in doc   ^                   +
-	 *	             if it's correct.   |                   |
-	 *	                                |                   |
-	 *	                      +---------+--------+          |
-	 *	Panel.urlInput#value  | Value validation |          |  User clicked "Link" in
-	 *	       is validated.  +---------+--------+          |  the toolbar. Retrieving
-	 *	                                |                   |  URL from Document and setting
-	 *	             PanelModel fires   |                   |  PanelModel#url.
-	 *	          PanelModel#execute.   +                   v
-	 *
-	 *	                              +-----------------------+
-	 *	                              | url: 'http://foo.com' |
-	 *	                              +-----------------------+
-	 *	                                      PanelModel
-	 *	                                ^                   +
-	 *	                                |                   |  Input field is
-	 *	                  User clicked  |                   |  in sync with
-	 *	                       "Save".  |                   |  PanelModel#url.
-	 *	                                +                   v
+	 * Creates the {@link link.ui.LinkBalloonPanel} instance,
+	 * attaches {@link link.LinkBalloonPanelModel} events to the link and unlink commands
+	 * and applies behaviors specific for this panel.
 	 *
-	 *	                            +--------------------------+
-	 *	                            | +----------------------+ |
-	 *	                            | |http://foo.com        | |
-	 *	                            | +----------------------+ |
-	 *	                            |                   +----+ |
-	 *	                            |                   |Save| |
-	 *	                            |                   +----+ |
-	 *	                            +--------------------------+
 	 * @private
 	 * @returns {link.ui.LinkBalloonPanel} Link balloon panel instance.
 	 */
@@ -178,40 +141,75 @@ export default class Link extends Feature {
 		const balloonPanel = new LinkBalloonPanel( panelModel, new LinkBalloonPanelView( editor.locale ) );
 
 		// Observe `LinkBalloonPanelMode#executeLink` event from within the model of the panel,
-		// which means that the `Save` button has been clicked.
+		// which means that form has been submitted.
 		this.listenTo( panelModel, 'executeLink', () => {
 			editor.execute( 'link', balloonPanel.urlInput.value );
-			balloonPanel.view.hide();
+			this._hidePanel( { focusEditable: true } );
 		} );
 
 		// Observe `LinkBalloonPanelMode#executeUnlink` event from within the model of the panel,
 		// which means that the `Unlink` button has been clicked.
 		this.listenTo( panelModel, 'executeUnlink', () => {
 			editor.execute( 'unlink' );
-			balloonPanel.view.hide();
+			this._hidePanel( { focusEditable: true } );
 		} );
 
-		// Always focus editor on panel hide.
+		// Observe `LinkBalloonPanelMode#executeCancel` event from within the model of the panel,
+		// which means that the `Cancel` button has been clicked.
+		this.listenTo( panelModel, 'executeCancel', () => this._hidePanel( { focusEditable: true } ) );
+
+		// Handle `Ctrl+L` keystroke and show panel.
+		editor.keystrokes.set( 'CTRL+L', () => {
+			this._attachPanelToElement();
+			balloonPanel.urlInput.view.select();
+		} );
+
+		// Attach close by `Esc` press and click out of panel actions on panel show, on panel hide clean up listeners.
 		this.listenTo( balloonPanel.view.model, 'change:isVisible', ( evt, propertyName, value ) => {
-			if ( !value ) {
-				viewDocument.focus();
+			if ( value ) {
+				// Handle close by `Esc`.
+				balloonPanel.view.listenTo( document, 'keydown', this._closePanelOnEsc.bind( this ) );
+
+				// Handle close by clicking out of the panel.
+				// Note that it is not handled by a `click` event, this is because clicking on link button or directly on link element
+				// opens and closes panel at the same time.
+				balloonPanel.view.listenTo( document, 'mouseup', ( evt, domEvt ) => {
+					// Do nothing when the panel was clicked.
+					if ( balloonPanel.view.element.contains( domEvt.target ) ) {
+						return;
+					}
+
+					// When click was out of the panel then hide it.
+					balloonPanel.view.hide();
+				} );
+			} else {
+				balloonPanel.view.stopListening( document );
 			}
 		} );
 
-		// Hide panel on editor focus.
-		// @TODO replace it by some FocusManager.
-		viewDocument.on( 'focus', () => balloonPanel.view.hide() );
-
-		// Handle click on document and show panel when selection is placed in the link element.
+		// Handle click on document and show panel when selection is placed inside the link element.
+		// Keep panel open until selection will be inside the same link element.
 		viewDocument.on( 'click', () => {
-			if ( viewDocument.selection.isCollapsed && linkCommand.value !== undefined ) {
+			const viewSelection = viewDocument.selection;
+			const parentLink = getPositionParentLink( viewSelection.getFirstPosition() );
+
+			if ( viewSelection.isCollapsed && parentLink ) {
 				this._attachPanelToElement();
+
+				this.listenTo( viewDocument, 'render', () => {
+					const currentParentLink = getPositionParentLink( viewSelection.getFirstPosition() );
+
+					if ( !viewSelection.isCollapsed || parentLink !== currentParentLink ) {
+						this._hidePanel();
+					} else {
+						this._attachPanelToElement( parentLink );
+					}
+				} );
+
+				this.listenTo( balloonPanel.view.model, 'change:isVisible', () => this.stopListening( viewDocument ) );
 			}
 		} );
 
-		// Handle `Ctrl+L` keystroke and show panel.
-		editor.keystrokes.set( 'CTRL+L', () => this._attachPanelToElement() );
-
 		// Append panel element to body.
 		editor.ui.add( 'body', balloonPanel );
 
@@ -223,22 +221,18 @@ export default class Link extends Feature {
 	 * 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.
 	 *
-	 * Input inside panel will be focused.
-	 *
 	 * @private
+	 * @param {core.view.LinkElement} [parentLink] Target element.
 	 */
-	_attachPanelToElement() {
+	_attachPanelToElement( parentLink ) {
 		const viewDocument = this.editor.editing.view;
 		const domEditableElement = viewDocument.domConverter.getCorrespondingDomElement( viewDocument.selection.editableElement );
-
-		const viewSelectionParent = viewDocument.selection.getFirstPosition().parent;
-		const viewSelectionParentAncestors = viewSelectionParent.getAncestors();
-		const linkElement = viewSelectionParentAncestors.find( ( ancestor ) => ancestor instanceof LinkElement );
+		const targetLink = parentLink || getPositionParentLink( viewDocument.selection.getFirstPosition() );
 
 		// When selection is inside link element, then attach panel to this element.
-		if ( linkElement ) {
+		if ( targetLink ) {
 			this.balloonPanel.view.attachTo(
-				viewDocument.domConverter.getCorrespondingDomElement( linkElement ),
+				viewDocument.domConverter.getCorrespondingDomElement( targetLink ),
 				domEditableElement
 			);
 		}
@@ -249,8 +243,44 @@ export default class Link extends Feature {
 				domEditableElement
 			);
 		}
+	}
+
+	/**
+	 * Hides {@link link#balloonPanel LinkBalloonPanel}.
+	 *
+	 * @private
+	 * @param {Object} [options={}] Additional options.
+	 * @param {Boolean} [options.focusEditable=false] When `true` then editable focus will be restored on panel hide.
+	 */
+	_hidePanel( options = {} ) {
+		this.balloonPanel.view.hide();
 
-		// Set focus to the panel input.
-		this.balloonPanel.urlInput.view.select();
+		if ( options.focusEditable ) {
+			this.editor.editing.view.focus();
+		}
 	}
+
+	/**
+	 * Hides balloon panel on `ESC` key press event and restores editor focus.
+	 *
+	 * **Note**: this method is `@protected` for testing purposes only.
+	 *
+	 * @protected
+	 * @param {utils.EventInfo} evt Information about the event.
+	 * @param {KeyboardEvent} domEvt DOM `keydown` event.
+	 */
+	_closePanelOnEsc( evt, domEvt ) {
+		if ( domEvt.keyCode == keyCodes.esc ) {
+			this._hidePanel( { focusEditable: true } );
+		}
+	}
+}
+
+// Get position parent LinkElement.
+//
+// @private
+// @param {engine.view.Position} position
+// @returns {link.LinkElement|null}
+function getPositionParentLink( position ) {
+	return position.parent.getAncestors().find( ( ancestor ) => ancestor instanceof LinkElement );
 }

+ 1 - 1
packages/ckeditor5-link/src/ui/linkballoonpanel.js

@@ -155,7 +155,7 @@ export default class LinkBalloonPanel extends BalloonPanel {
 			withText: true
 		} );
 
-		cancelModel.on( 'execute', () => this.view.hide() );
+		cancelModel.on( 'execute', () => this.model.fire( 'executeCancel' ) );
 
 		return new Button( cancelModel, new ButtonView( this.locale ) );
 	}

+ 265 - 90
packages/ckeditor5-link/tests/link.js

@@ -15,6 +15,7 @@ import LinkEngine from '/ckeditor5/link/linkengine.js';
 import Button from '/ckeditor5/ui/button/button.js';
 import LinkBalloonPanel from '/ckeditor5/link/ui/linkballoonpanel.js';
 
+import Range from '/ckeditor5/engine/view/range.js';
 import ClickObserver from '/ckeditor5/engine/view/observer/clickobserver.js';
 
 testUtils.createSinonSandbox();
@@ -72,36 +73,14 @@ describe( 'Link', () => {
 			expect( model.isEnabled ).to.be.false;
 		} );
 
-		it( 'should open panel on linkButton#model execute event, when editor is focused', () => {
-			editor.editing.view.isFocused = true;
-
+		it( 'should open panel on linkButton#model execute event', () => {
 			linkButton.model.fire( 'execute' );
 
 			expect( linkFeature.balloonPanel.view.isVisible ).to.true;
 		} );
 
-		it( 'should select panel input value when panel is opened', () => {
-			const selectSpy = sinon.spy( linkFeature.balloonPanel.urlInput.view, 'select' );
-
-			editor.editing.view.isFocused = true;
-
-			linkButton.model.fire( 'execute' );
-
-			expect( selectSpy.calledOnce ).to.true;
-
-			selectSpy.restore();
-		} );
-
-		it( 'should not open panel on linkButton#model execute event, when editor is not focused', () => {
-			editor.editing.view.isFocused = false;
-
-			linkButton.model.fire( 'execute' );
-
-			expect( linkFeature.balloonPanel.view.isVisible ).to.false;
-		} );
-
 		it( 'should open panel attached to the link element, when collapsed selection is inside link element', () => {
-			const attachToSpy = sinon.spy( balloonPanel.view, 'attachTo' );
+			const attachToSpy = testUtils.sinon.spy( balloonPanel.view, 'attachTo' );
 
 			editor.document.schema.allow( { name: '$text', inside: '$root' } );
 			setModelData( editor.document, '<$text linkHref="url">some[] url</$text>' );
@@ -115,7 +94,7 @@ describe( 'Link', () => {
 		} );
 
 		it( 'should open panel attached to the selection, when there is non-collapsed selection', () => {
-			const attachToSpy = sinon.spy( balloonPanel.view, 'attachTo' );
+			const attachToSpy = testUtils.sinon.spy( balloonPanel.view, 'attachTo' );
 
 			editor.document.schema.allow( { name: '$text', inside: '$root' } );
 			setModelData( editor.document, 'so[me ur]l' );
@@ -127,6 +106,16 @@ describe( 'Link', () => {
 
 			expect( attachToSpy.calledWithExactly( selectedRange, editorElement ) ).to.true;
 		} );
+
+		it( 'should select panel input value when panel is opened', () => {
+			const selectUrlInputSpy = testUtils.sinon.spy( balloonPanel.urlInput.view, 'select' );
+
+			editor.editing.view.isFocused = true;
+
+			linkButton.model.fire( 'execute' );
+
+			expect( selectUrlInputSpy.calledOnce ).to.true;
+		} );
 	} );
 
 	describe( 'unlink toolbar button', () => {
@@ -152,114 +141,300 @@ describe( 'Link', () => {
 			expect( executeSpy.calledOnce ).to.true;
 			expect( executeSpy.calledWithExactly( 'unlink' ) ).to.true;
 		} );
+	} );
 
-		it( 'should hide panel on unlinkButton#model execute event', () => {
-			balloonPanel.view.model.isVisible = true;
-
-			unlinkButton.model.fire( 'execute' );
+	describe( 'link balloon panel', () => {
+		let hidePanelSpy, focusEditableSpy;
 
-			expect( balloonPanel.view.model.isVisible ).to.false;
+		beforeEach( () => {
+			hidePanelSpy = testUtils.sinon.spy( balloonPanel.view, 'hide' );
+			focusEditableSpy = testUtils.sinon.spy( editor.editing.view, 'focus' );
 		} );
-	} );
 
-	describe( 'link balloon panel', () => {
-		it( 'should create LinkBalloonPanel component', () => {
+		it( 'should be created', () => {
 			expect( balloonPanel ).to.instanceOf( LinkBalloonPanel );
 		} );
 
-		it( 'should bind balloonPanel#model to link command', () => {
-			const model = balloonPanel.model;
-			const command = editor.commands.get( 'link' );
+		it( 'should be appended to the document body', () => {
+			expect( document.body.contains( balloonPanel.view.element ) );
+		} );
 
-			expect( model.url ).to.undefined;
+		it( 'should open with selected url input on `CTRL+L` keystroke', () => {
+			const selectUrlInputSpy = testUtils.sinon.spy( balloonPanel.urlInput.view, 'select' );
 
-			command.value = 'http://cksource.com';
+			editor.keystrokes.press( { keyCode: keyCodes.l, ctrlKey: true } );
 
-			expect( model.url ).to.equal( 'http://cksource.com' );
+			expect( balloonPanel.view.model.isVisible ).to.true;
+			expect( selectUrlInputSpy.calledOnce ).to.true;
 		} );
 
-		it( 'should execute link command on balloonPanel#model executeLink event', () => {
-			const executeSpy = testUtils.sinon.spy( editor, 'execute' );
+		describe( 'binding', () => {
+			it( 'should bind balloonPanel#model to link command', () => {
+				const model = balloonPanel.model;
+				const command = editor.commands.get( 'link' );
 
-			balloonPanel.model.url = 'http://cksource.com';
-			balloonPanel.model.fire( 'executeLink' );
+				expect( model.url ).to.undefined;
 
-			expect( executeSpy.calledOnce ).to.true;
-			expect( executeSpy.calledWithExactly( 'link', 'http://cksource.com' ) ).to.true;
-		} );
+				command.value = 'http://cksource.com';
 
-		it( 'should hide balloon panel on balloonPanel#model execute event', () => {
-			const hideSpy = testUtils.sinon.spy( balloonPanel.view, 'hide' );
+				expect( model.url ).to.equal( 'http://cksource.com' );
+			} );
 
-			balloonPanel.model.fire( 'executeLink' );
+			it( 'should execute link command on balloonPanel#model executeLink event', () => {
+				const executeSpy = testUtils.sinon.spy( editor, 'execute' );
 
-			expect( hideSpy.calledOnce ).to.true;
-		} );
+				balloonPanel.model.url = 'http://cksource.com';
+				balloonPanel.model.fire( 'executeLink' );
 
-		it( 'should execute unlink command on balloonPanel#model executeUnlink event', () => {
-			const executeSpy = testUtils.sinon.spy( editor, 'execute' );
+				expect( executeSpy.calledOnce ).to.true;
+				expect( executeSpy.calledWithExactly( 'link', 'http://cksource.com' ) ).to.true;
+			} );
 
-			balloonPanel.model.fire( 'executeUnlink' );
+			it( 'should hide and focus editable on balloonPanel#model executeLink event', () => {
+				balloonPanel.model.fire( 'executeLink' );
 
-			expect( executeSpy.calledOnce ).to.true;
-			expect( executeSpy.calledWithExactly( 'unlink' ) ).to.true;
-		} );
+				expect( hidePanelSpy.calledOnce ).to.true;
+				expect( focusEditableSpy.calledOnce ).to.true;
+			} );
 
-		it( 'should hide balloon panel on balloonPanel#model executeUnlink event', () => {
-			const hideSpy = testUtils.sinon.spy( balloonPanel.view, 'hide' );
+			it( 'should execute unlink command on balloonPanel#model executeUnlink event', () => {
+				const executeSpy = testUtils.sinon.spy( editor, 'execute' );
 
-			balloonPanel.model.fire( 'executeUnlink' );
+				balloonPanel.model.fire( 'executeUnlink' );
 
-			expect( hideSpy.calledOnce ).to.true;
-		} );
+				expect( executeSpy.calledOnce ).to.true;
+				expect( executeSpy.calledWithExactly( 'unlink' ) ).to.true;
+			} );
 
-		it( 'should append panel element to the body', () => {
-			expect( document.body.contains( balloonPanel.view.element ) );
-		} );
+			it( 'should hide and focus editable on balloonPanel#model executeUnlink event', () => {
+				balloonPanel.model.fire( 'executeUnlink' );
 
-		it( 'should open panel on `CTRL+L` keystroke', () => {
-			editor.keystrokes.press( { keyCode: keyCodes.l, ctrlKey: true } );
+				expect( hidePanelSpy.calledOnce ).to.true;
+				expect( focusEditableSpy.calledOnce ).to.true;
+			} );
 
-			expect( balloonPanel.view.model.isVisible ).to.true;
+			it( 'should hide and focus editable on balloonPanel#model executeCancel event', () => {
+				balloonPanel.model.fire( 'executeCancel' );
+
+				expect( hidePanelSpy.calledOnce ).to.true;
+				expect( focusEditableSpy.calledOnce ).to.true;
+			} );
 		} );
 
-		it( 'should focus editor on balloonPanel hide', () => {
-			const focusSpy = sinon.spy( editor.editing.view, 'focus' );
+		describe( 'close listeners', () => {
+			describe( 'keyboard', () => {
+				it( 'should listen keyboard events when is open', () => {
+					const escCloseSpy = testUtils.sinon.spy( linkFeature, '_closePanelOnEsc' );
 
-			balloonPanel.view.model.isVisible = true;
+					balloonPanel.view.model.isVisible = true;
+					document.dispatchEvent( new Event( 'keydown' ) );
 
-			balloonPanel.view.model.isVisible = false;
+					expect( escCloseSpy.calledOnce ).to.true;
+				} );
 
-			expect( focusSpy.calledOnce ).to.true;
-		} );
+				it( 'should not listen keyboard events when is closed', () => {
+					const escCloseSpy = testUtils.sinon.spy( linkFeature, '_closePanelOnEsc' );
 
-		it( 'should hide panel on editor focus event', () => {
-			balloonPanel.view.model.isVisible = true;
+					balloonPanel.view.model.isVisible = false;
+					document.dispatchEvent( new Event( 'keydown' ) );
 
-			editor.editing.view.fire( 'focus' );
+					expect( escCloseSpy.calledOnce ).to.false;
+				} );
 
-			expect( balloonPanel.view.model.isVisible ).to.false;
-		} );
+				it( 'should stop listening keyboard events after close', () => {
+					const escCloseSpy = testUtils.sinon.spy( linkFeature, '_closePanelOnEsc' );
 
-		it( 'should open panel on editor click, when selection is inside link element', () => {
-			const observer = editor.editing.view.getObserver( ClickObserver );
+					balloonPanel.view.model.isVisible = true;
+					balloonPanel.view.model.isVisible = false;
 
-			editor.document.schema.allow( { name: '$text', inside: '$root' } );
-			setModelData( editor.document, '<$text linkHref="url">some[] url</$text>' );
+					document.dispatchEvent( new Event( 'keydown' ) );
 
-			observer.fire( 'click', { target: document.body } );
+					expect( escCloseSpy.notCalled ).to.true;
+				} );
+			} );
 
-			expect( balloonPanel.view.model.isVisible ).to.true;
+			describe( 'mouse', () => {
+				it( 'should not close on click inside the panel', () => {
+					balloonPanel.view.model.isVisible = true;
+					balloonPanel.view.element.dispatchEvent( new Event( 'mouseup', { bubbles: true } ) );
+
+					expect( hidePanelSpy.notCalled ).to.true;
+				} );
+
+				it( 'should close and not focus editable on click outside the panel', () => {
+					balloonPanel.view.model.isVisible = true;
+					document.dispatchEvent( new Event( 'mouseup' ) );
+
+					expect( hidePanelSpy.calledOnce ).to.true;
+					expect( focusEditableSpy.notCalled ).to.true;
+				} );
+
+				it( 'should not listen mouse events before open', () => {
+					balloonPanel.view.model.isVisible = false;
+					document.dispatchEvent( new Event( 'mouseup' ) );
+
+					expect( hidePanelSpy.notCalled ).to.true;
+				} );
+
+				it( 'should stop listening mouse events after closed', () => {
+					balloonPanel.view.model.isVisible = true;
+					balloonPanel.view.model.isVisible = false;
+
+					document.dispatchEvent( new Event( 'mouseup' ) );
+
+					expect( hidePanelSpy.notCalled ).to.true;
+				} );
+			} );
 		} );
 
-		it( 'should not open panel on editor click, when selection is not inside link element', () => {
-			const observer = editor.editing.view.getObserver( ClickObserver );
+		describe( 'click on editable', () => {
+			it( 'should open with not selected url input when collapsed selection is inside link element', () => {
+				const selectUrlInputSpy = testUtils.sinon.spy( balloonPanel.urlInput.view, 'select' );
+				const observer = editor.editing.view.getObserver( ClickObserver );
+
+				editor.document.schema.allow( { name: '$text', inside: '$root' } );
+				setModelData( editor.document, '<$text linkHref="url">fo[]o</$text>' );
+
+				observer.fire( 'click', { target: document.body } );
+
+				expect( balloonPanel.view.model.isVisible ).to.true;
+				expect( selectUrlInputSpy.notCalled ).to.true;
+			} );
+
+			it( 'should keep open and update position until collapsed selection stay inside the same link element', () => {
+				const observer = editor.editing.view.getObserver( ClickObserver );
+
+				editor.document.schema.allow( { name: '$text', inside: '$root' } );
+				setModelData( editor.document, '<$text linkHref="url">b[]ar</$text>' );
+
+				const root = editor.editing.view.getRoot();
+				const text = root.getChild( 0 ).getChild( 0 );
+
+				observer.fire( 'click', { target: document.body } );
+
+				expect( balloonPanel.view.model.isVisible ).to.true;
+
+				const attachToSpy = testUtils.sinon.spy( balloonPanel.view, 'attachTo' );
+
+				editor.editing.view.selection.setRanges( [ Range.createFromParentsAndOffsets( text, 1, text, 1 ) ], true );
+				editor.editing.view.render();
+
+				expect( balloonPanel.view.model.isVisible ).to.true;
+				expect( attachToSpy.calledOnce ).to.true;
+			} );
+
+			it( 'should close when selection goes outside the link element', () => {
+				const observer = editor.editing.view.getObserver( ClickObserver );
+
+				editor.document.schema.allow( { name: '$text', inside: '$root' } );
+				setModelData( editor.document, 'foo <$text linkHref="url">b[]ar</$text>' );
+
+				const root = editor.editing.view.getRoot();
+				const text = root.getChild( 0 );
+
+				observer.fire( 'click', { target: document.body } );
+
+				expect( balloonPanel.view.model.isVisible ).to.true;
+
+				editor.editing.view.selection.setRanges( [ Range.createFromParentsAndOffsets( text, 3, text, 3 ) ], true );
+				editor.editing.view.render();
+
+				expect( balloonPanel.view.model.isVisible ).to.false;
+			} );
+
+			it( 'should close when selection goes to the other link element with the same href', () => {
+				const observer = editor.editing.view.getObserver( ClickObserver );
+
+				editor.document.schema.allow( { name: '$text', inside: '$root' } );
+				setModelData( editor.document, '<$text linkHref="url">f[]oo</$text> bar <$text linkHref="url">biz</$text>' );
+
+				const root = editor.editing.view.getRoot();
+				const text = root.getChild( 2 ).getChild( 0 );
+
+				observer.fire( 'click', { target: document.body } );
+
+				expect( balloonPanel.view.model.isVisible ).to.true;
+
+				editor.editing.view.selection.setRanges( [ Range.createFromParentsAndOffsets( text, 1, text, 1 ) ], true );
+				editor.editing.view.render();
+
+				expect( balloonPanel.view.model.isVisible ).to.false;
+			} );
+
+			it( 'should close when selection becomes non-collapsed', () => {
+				const observer = editor.editing.view.getObserver( ClickObserver );
+
+				editor.document.schema.allow( { name: '$text', inside: '$root' } );
+				setModelData( editor.document, '<$text linkHref="url">f[]oo</$text>' );
+
+				const root = editor.editing.view.getRoot();
+				const text = root.getChild( 0 ).getChild( 0 );
+
+				observer.fire( 'click', { target: {} } );
+
+				expect( balloonPanel.view.model.isVisible ).to.true;
+
+				editor.editing.view.selection.setRanges( [ Range.createFromParentsAndOffsets( text, 1, text, 2 ) ] );
+				editor.editing.view.render();
+
+				expect( balloonPanel.view.model.isVisible ).to.false;
+			} );
+
+			it( 'should stop updating position after close', () => {
+				const observer = editor.editing.view.getObserver( ClickObserver );
+
+				editor.document.schema.allow( { name: '$text', inside: '$root' } );
+				setModelData( editor.document, '<$text linkHref="url">b[]ar</$text>' );
+
+				const root = editor.editing.view.getRoot();
+				const text = root.getChild( 0 ).getChild( 0 );
+
+				observer.fire( 'click', { target: {} } );
+
+				expect( balloonPanel.view.model.isVisible ).to.true;
+
+				balloonPanel.view.model.isVisible = false;
+
+				const attachToSpy = testUtils.sinon.spy( balloonPanel.view, 'attachTo' );
+
+				editor.editing.view.selection.setRanges( [ Range.createFromParentsAndOffsets( text, 2, text, 2 ) ], true );
+				editor.editing.view.render();
+
+				expect( attachToSpy.notCalled ).to.true;
+			} );
+
+			it( 'should not open when selection is not inside link element', () => {
+				const observer = editor.editing.view.getObserver( ClickObserver );
+
+				setModelData( editor.document, '[]' );
+
+				observer.fire( 'click', { target: {} } );
+
+				expect( balloonPanel.view.model.isVisible ).to.false;
+			} );
+
+			it( 'should not open when selection is non-collapsed', () => {
+				const observer = editor.editing.view.getObserver( ClickObserver );
+
+				editor.document.schema.allow( { name: '$text', inside: '$root' } );
+				setModelData( editor.document, '<$text linkHref="url">f[o]o</$text>' );
+
+				observer.fire( 'click', { target: document.body } );
+
+				expect( balloonPanel.view.model.isVisible ).to.false;
+			} );
+		} );
 
-			setModelData( editor.document, '[]' );
+		describe( '_closePanelOnEsc', () => {
+			it( 'should hide panel and focus editable on ESC press', () => {
+				const eventInfo = {};
+				const domEvent = { keyCode: 27 };
 
-			observer.fire( 'click', { target: document.body } );
+				linkFeature._closePanelOnEsc( eventInfo, domEvent );
 
-			expect( balloonPanel.view.model.isVisible ).to.false;
+				expect( hidePanelSpy.calledOnce ).to.true;
+				expect( focusEditableSpy.calledOnce ).to.true;
+			} );
 		} );
 	} );
 } );

+ 1 - 1
packages/ckeditor5-link/tests/manual/link.js

@@ -8,7 +8,7 @@
 import ClassicEditor from '/ckeditor5/editor-classic/classic.js';
 
 ClassicEditor.create( document.querySelector( '#editor' ), {
-	features: [ 'link', 'typing', 'paragraph', 'undo' ],
+	features: [ 'link', 'typing', 'paragraph', 'undo', 'enter' ],
 	toolbar: [ 'link', 'unlink', 'undo', 'redo' ]
 } )
 .then( editor => {

+ 5 - 3
packages/ckeditor5-link/tests/ui/linkballoonpanel.js

@@ -109,12 +109,14 @@ describe( 'LinkBalloonPanel', () => {
 					expect( linkBalloonPanel.form.collections.get( 'actions' ).get( 1 ) ).to.deep.equal( linkBalloonPanel.cancelButton );
 				} );
 
-				it( 'should hide LinkBalloonPanel on cancelButton.model#execute event', () => {
-					const hideSpy = sinon.spy( linkBalloonPanel.view, 'hide' );
+				it( 'should fire model#executeCancel event on cancelButton.model#execute event', () => {
+					const executeSpy = sinon.spy();
+
+					model.on( 'executeCancel', executeSpy );
 
 					linkBalloonPanel.cancelButton.model.fire( 'execute' );
 
-					expect( hideSpy.calledOnce ).to.true;
+					expect( executeSpy.calledOnce ).to.true;
 				} );
 			} );