diff --git a/src/link.js b/src/link.js index 6852add..227a130 100644 --- a/src/link.js +++ b/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 Ctrl+K keystroke. + * The link plugin. It introduces the Link and Unlink buttons and the Ctrl+K 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 ) + }; } } diff --git a/tests/link.js b/tests/link.js index cdb2eb0..c3d4e78 100644 --- a/tests/link.js +++ b/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' ); 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' ); + + // 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; } ); } );