diff --git a/src/link.js b/src/link.js index 8252c04..f8d905b 100644 --- a/src/link.js +++ b/src/link.js @@ -8,27 +8,14 @@ */ import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; -import ClickObserver from '@ckeditor/ckeditor5-engine/src/view/observer/clickobserver'; -import Range from '@ckeditor/ckeditor5-engine/src/view/range'; -import LinkEngine from './linkengine'; -import { isLinkElement } from './utils'; -import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon'; - -import clickOutsideHandler from '@ckeditor/ckeditor5-ui/src/bindings/clickoutsidehandler'; - -import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview'; -import LinkFormView from './ui/linkformview'; -import LinkActionsView from './ui/linkactionsview'; - -import linkIcon from '../theme/icons/link.svg'; - -const linkKeystroke = 'Ctrl+K'; +import LinkEditing from './linkediting'; +import LinkUI from './linkui'; /** * The link plugin. It introduces the Link and Unlink buttons and the Ctrl+K keystroke. * - * It uses the {@link module:link/linkengine~LinkEngine link engine plugin} and the - * {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon contextual balloon plugin}. + * It loads the {@link module:link/linkediting~LinkEditing link editing feature} + * and {@link module:link/linkui~LinkUI link UI feature}. * * @extends module:core/plugin~Plugin */ @@ -37,7 +24,7 @@ export default class Link extends Plugin { * @inheritDoc */ static get requires() { - return [ LinkEngine, ContextualBalloon ]; + return [ LinkEditing, LinkUI ]; } /** @@ -46,492 +33,4 @@ export default class Link extends Plugin { static get pluginName() { return 'Link'; } - - /** - * @inheritDoc - */ - init() { - const editor = this.editor; - - editor.editing.view.addObserver( ClickObserver ); - - /** - * The actions view displayed inside of the balloon. - * - * @member {module:link/ui/linkactionsview~LinkActionsView} - */ - this.actionsView = this._createActionsView(); - - /** - * The form view displayed inside the balloon. - * - * @member {module:link/ui/linkformview~LinkFormView} - */ - this.formView = this._createFormView(); - - /** - * The contextual balloon plugin instance. - * - * @private - * @member {module:ui/panel/balloon/contextualballoon~ContextualBalloon} - */ - this._balloon = editor.plugins.get( ContextualBalloon ); - - // Create toolbar buttons. - this._createToolbarLinkButton(); - - // Attach lifecycle actions to the the balloon. - this._enableUserBalloonInteractions(); - } - - /** - * Creates the {@link module:link/ui/linkactionsview~LinkActionsView} instance. - * - * @private - * @returns {module:link/ui/linkactionsview~LinkActionsView} The link actions view instance. - */ - _createActionsView() { - const editor = this.editor; - const actionsView = new LinkActionsView( editor.locale ); - const linkCommand = editor.commands.get( 'link' ); - const unlinkCommand = editor.commands.get( 'unlink' ); - - actionsView.bind( 'href' ).to( linkCommand, 'value' ); - actionsView.editButtonView.bind( 'isEnabled' ).to( linkCommand ); - actionsView.unlinkButtonView.bind( 'isEnabled' ).to( unlinkCommand ); - - // Execute unlink command after clicking on the "Edit" button. - this.listenTo( actionsView, 'edit', () => { - this._addFormView(); - } ); - - // Execute unlink command after clicking on the "Unlink" button. - this.listenTo( actionsView, 'unlink', () => { - editor.execute( 'unlink' ); - this._hideUI(); - } ); - - // Close the panel on esc key press when the **actions have focus**. - actionsView.keystrokes.set( 'Esc', ( data, cancel ) => { - this._hideUI(); - cancel(); - } ); - - return actionsView; - } - - /** - * Creates the {@link module:link/ui/linkformview~LinkFormView} instance. - * - * @private - * @returns {module:link/ui/linkformview~LinkFormView} The link form instance. - */ - _createFormView() { - const editor = this.editor; - const formView = new LinkFormView( editor.locale ); - const linkCommand = editor.commands.get( 'link' ); - - formView.urlInputView.bind( 'value' ).to( linkCommand, 'value' ); - - // Form elements should be read-only when corresponding commands are disabled. - formView.urlInputView.bind( 'isReadOnly' ).to( linkCommand, 'isEnabled', value => !value ); - formView.saveButtonView.bind( 'isEnabled' ).to( linkCommand ); - - // Execute link command after clicking the "Save" button. - this.listenTo( formView, 'submit', () => { - editor.execute( 'link', formView.urlInputView.inputView.element.value ); - this._removeFormView(); - } ); - - // Hide the panel after clicking the "Cancel" button. - this.listenTo( formView, 'cancel', () => { - this._removeFormView(); - } ); - - // Close the panel on esc key press when the **form has focus**. - formView.keystrokes.set( 'Esc', ( data, cancel ) => { - this._removeFormView(); - cancel(); - } ); - - return formView; - } - - /** - * Creates a toolbar Link button. Clicking this button will show - * a {@link #_balloon} attached to the selection. - * - * @private - */ - _createToolbarLinkButton() { - const editor = this.editor; - const linkCommand = editor.commands.get( 'link' ); - const t = editor.t; - - // Handle the `Ctrl+K` keystroke and show the panel. - editor.keystrokes.set( linkKeystroke, ( keyEvtData, cancel ) => { - // Prevent focusing the search bar in FF and opening new tab in Edge. #153, #154. - cancel(); - - if ( linkCommand.isEnabled ) { - this._showUI(); - } - } ); - - editor.ui.componentFactory.add( 'link', locale => { - const button = new ButtonView( locale ); - - button.isEnabled = true; - button.label = t( 'Link' ); - button.icon = linkIcon; - button.keystroke = linkKeystroke; - button.tooltip = true; - - // Bind button to the command. - button.bind( 'isEnabled' ).to( linkCommand, 'isEnabled' ); - - // Show the panel on button click. - this.listenTo( button, 'execute', () => this._showUI() ); - - return button; - } ); - } - - /** - * Attaches actions that control whether the balloon panel containing the - * {@link #formView} is visible or not. - * - * @private - */ - _enableUserBalloonInteractions() { - const viewDocument = this.editor.editing.view; - - // Handle click on view document and show panel when selection is placed inside the link element. - // Keep panel open until selection will be inside the same link element. - this.listenTo( viewDocument, 'click', () => { - const parentLink = this._getSelectedLinkElement(); - - if ( parentLink ) { - // Then show panel but keep focus inside editor editable. - this._showUI(); - } - } ); - - // Focus the form if the balloon is visible and the Tab key has been pressed. - this.editor.keystrokes.set( 'Tab', ( data, cancel ) => { - if ( this._areActionsVisible && !this.actionsView.focusTracker.isFocused ) { - this.actionsView.focus(); - cancel(); - } - }, { - // Use the high priority because the link UI navigation is more important - // than other feature's actions, e.g. list indentation. - // https://github.com/ckeditor/ckeditor5-link/issues/146 - priority: 'high' - } ); - - // Close the panel on the Esc key press when the editable has focus and the balloon is visible. - this.editor.keystrokes.set( 'Esc', ( data, cancel ) => { - if ( this._isUIVisible ) { - this._hideUI(); - cancel(); - } - } ); - - // Close on click outside of balloon panel element. - clickOutsideHandler( { - emitter: this.formView, - activator: () => this._isUIVisible, - contextElements: [ this._balloon.view.element ], - callback: () => this._hideUI() - } ); - } - - /** - * Adds the {@link #actionsView} to the {@link #_balloon}. - * - * @protected - */ - _addActionsView() { - this._balloon.add( { - view: this.actionsView, - position: this._getBalloonPositionData() - } ); - } - - /** - * Adds the {@link #formView} to the {@link #_balloon}. - * - * @protected - */ - _addFormView() { - const editor = this.editor; - const linkCommand = editor.commands.get( 'link' ); - - this._balloon.add( { - view: this.formView, - position: this._getBalloonPositionData() - } ); - - this.formView.urlInputView.select(); - - // Make sure that each time the panel shows up, the URL field remains in sync with the value of - // the command. If the user typed in the input, then canceled the balloon (`urlInputView#value` stays - // unaltered) and re-opened it without changing the value of the link command (e.g. because they - // clicked the same link), they would see the old value instead of the actual value of the command. - // https://github.com/ckeditor/ckeditor5-link/issues/78 - // https://github.com/ckeditor/ckeditor5-link/issues/123 - this.formView.urlInputView.inputView.element.value = linkCommand.value || ''; - } - - /** - * Removes the {@link #formView} from the {@link #_balloon}. - * - * @protected - */ - _removeFormView() { - if ( this._isFormInPanel ) { - this._balloon.remove( this.formView ); - - // Because the form has an input which has focus, the focus must be brought back - // to the editor. Otherwise, it would be lost. - this.editor.editing.view.focus(); - } - } - - /** - * Shows the right kind of the UI for current state of the command. It's either - * {@link #formView} or {@link #actionsView}. - * - * @private - */ - _showUI() { - const editor = this.editor; - const linkCommand = editor.commands.get( 'link' ); - - if ( !linkCommand.isEnabled || this._isUIInPanel ) { - return; - } - - // When there's no link under the selection, go straight to the editing UI. - if ( !this._getSelectedLinkElement() ) { - this._addActionsView(); - this._addFormView(); - } - // Otherwise display just the actions UI. - else { - this._addActionsView(); - } - - // Begin responding to view#render once the UI is added. - this._startUpdatingUIOnViewRender(); - } - - /** - * Removes the {@link #formView} from the {@link #_balloon}. - * - * See {@link #_addFormView}, {@link #_addActionsView}. - * - * @protected - */ - _hideUI() { - if ( !this._isUIInPanel ) { - return; - } - - const editingView = this.editor.editing.view; - - this.stopListening( editingView, 'render' ); - - // Remove form first because it's on top of the stack. - this._removeFormView(); - - // Then remove the actions view because it's beneath the form. - this._balloon.remove( this.actionsView ); - - // Make sure the focus always gets back to the editable. - editingView.focus(); - } - - /** - * Makes the UI react to the {@link module:engine/view/document~Document#event:render} in the view - * document to reposition itself as the document changes. - * - * See: {@link #_hideUI} to learn when the UI stops reacting to the `render` event. - * - * @protected - */ - _startUpdatingUIOnViewRender() { - const editor = this.editor; - const editing = editor.editing; - const editingView = editing.view; - - let prevSelectedLink = this._getSelectedLinkElement(); - let prevSelectionParent = getSelectionParent(); - - this.listenTo( editingView, 'render', () => { - const selectedLink = this._getSelectedLinkElement(); - const selectionParent = getSelectionParent(); - - // Hide the panel if: - // - // * the selection went out of the EXISTING link element. E.g. user moved the caret out - // of the link, - // * the selection went to a different parent when creating a NEW link. E.g. someone - // else modified the document. - // * the selection has expanded (e.g. displaying link actions then pressing SHIFT+Right arrow). - // - // Note: #_getSelectedLinkElement will return a link for a non-collapsed selection only - // when fully selected. - if ( ( prevSelectedLink && !selectedLink ) || - ( !prevSelectedLink && selectionParent !== prevSelectionParent ) ) { - this._hideUI(); - } - // Update the position of the panel when: - // * the selection remains in the original link element, - // * there was no link element in the first place, i.e. creating a new link - else { - // If still in a link element, simply update the position of the balloon. - // If there was no link (e.g. inserting one), the balloon must be moved - // to the new position in the editing view (a new native DOM range). - this._balloon.updatePosition( this._getBalloonPositionData() ); - } - - prevSelectedLink = selectedLink; - prevSelectionParent = selectionParent; - } ); - - function getSelectionParent() { - return editingView.selection.focus.getAncestors() - .reverse() - .find( node => node.is( 'element' ) ); - } - } - - /** - * Returns true when {@link #formView} is in the {@link #_balloon}. - * - * @readonly - * @protected - * @type {Boolean} - */ - get _isFormInPanel() { - return this._balloon.hasView( this.formView ); - } - - /** - * Returns true when {@link #actionsView} is in the {@link #_balloon}. - * - * @readonly - * @protected - * @type {Boolean} - */ - get _areActionsInPanel() { - return this._balloon.hasView( this.actionsView ); - } - - /** - * Returns true when {@link #actionsView} is in the {@link #_balloon} and it is - * currently visible. - * - * @readonly - * @protected - * @type {Boolean} - */ - get _areActionsVisible() { - return this._balloon.visibleView === this.actionsView; - } - - /** - * Returns true when {@link #actionsView} or {@link #formView} is in the {@link #_balloon}. - * - * @readonly - * @protected - * @type {Boolean} - */ - get _isUIInPanel() { - return this._isFormInPanel || this._areActionsInPanel; - } - - /** - * Returns true when {@link #actionsView} or {@link #formView} is in the {@link #_balloon} and it is - * currently visible. - * - * @readonly - * @protected - * @type {Boolean} - */ - get _isUIVisible() { - const visibleView = this._balloon.visibleView; - - return visibleView == this.formView || this._areActionsVisible; - } - - /** - * Returns positioning options for the {@link #_balloon}. They control the way the balloon is attached - * to the target element or selection. - * - * If the selection is collapsed and inside a link element, the panel will be attached to the - * entire link element. Otherwise, it will be attached to the selection. - * - * @private - * @returns {module:utils/dom/position~Options} - */ - _getBalloonPositionData() { - const viewDocument = this.editor.editing.view; - const targetLink = this._getSelectedLinkElement(); - - const target = targetLink ? - // When selection is inside link element, then attach panel to this element. - viewDocument.domConverter.mapViewToDom( targetLink ) : - // Otherwise attach panel to the selection. - viewDocument.domConverter.viewRangeToDom( viewDocument.selection.getFirstRange() ); - - return { target }; - } - - /** - * Returns the link {@link module:engine/view/attributeelement~AttributeElement} under - * the {@link module:engine/view/document~Document editing view's} selection or `null` - * if there is none. - * - * **Note**: For a non–collapsed selection the link element is only returned when **fully** - * selected and the **only** element within the selection boundaries. - * - * @private - * @returns {module:engine/view/attributeelement~AttributeElement|null} - */ - _getSelectedLinkElement() { - const selection = this.editor.editing.view.selection; - - if ( selection.isCollapsed ) { - return findLinkElementAncestor( selection.getFirstPosition() ); - } else { - // The range for fully selected link is usually anchored in adjacent text nodes. - // Trim it to get closer to the actual link element. - const range = selection.getFirstRange().getTrimmed(); - const startLink = findLinkElementAncestor( range.start ); - const endLink = findLinkElementAncestor( range.end ); - - if ( !startLink || startLink != endLink ) { - return null; - } - - // Check if the link element is fully selected. - if ( Range.createIn( startLink ).getTrimmed().isEqual( range ) ) { - return startLink; - } else { - return null; - } - } - } -} - -// Returns a link element if there's one among the ancestors of the provided `Position`. -// -// @private -// @param {module:engine/view/position~Position} View position to analyze. -// @returns {module:engine/view/attributeelement~AttributeElement|null} Link element at the position or null. -function findLinkElementAncestor( position ) { - return position.getAncestors().find( ancestor => isLinkElement( ancestor ) ); } diff --git a/src/linkengine.js b/src/linkediting.js similarity index 95% rename from src/linkengine.js rename to src/linkediting.js index 587a1e6..3ae4191 100644 --- a/src/linkengine.js +++ b/src/linkediting.js @@ -4,7 +4,7 @@ */ /** - * @module link/linkengine + * @module link/linkediting */ import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; @@ -21,7 +21,7 @@ import { createLinkElement } from './utils'; * * @extends module:core/plugin~Plugin */ -export default class LinkEngine extends Plugin { +export default class LinkEditing extends Plugin { /** * @inheritDoc */ diff --git a/src/linkui.js b/src/linkui.js new file mode 100644 index 0000000..3918368 --- /dev/null +++ b/src/linkui.js @@ -0,0 +1,536 @@ +/** + * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/** + * @module link/linkui + */ + +import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; +import ClickObserver from '@ckeditor/ckeditor5-engine/src/view/observer/clickobserver'; +import Range from '@ckeditor/ckeditor5-engine/src/view/range'; +import { isLinkElement } from './utils'; +import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon'; + +import clickOutsideHandler from '@ckeditor/ckeditor5-ui/src/bindings/clickoutsidehandler'; + +import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview'; +import LinkFormView from './ui/linkformview'; +import LinkActionsView from './ui/linkactionsview'; + +import linkIcon from '../theme/icons/link.svg'; + +const linkKeystroke = 'Ctrl+K'; + +/** + * The link UI plugin. It introduces the Link and Unlink buttons and the Ctrl+K keystroke. + * + * It uses the + * {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon contextual balloon plugin}. + * + * @extends module:core/plugin~Plugin + */ +export default class LinkUI extends Plugin { + /** + * @inheritDoc + */ + static get requires() { + return [ ContextualBalloon ]; + } + + /** + * @inheritDoc + */ + static get pluginName() { + return 'Link'; + } + + /** + * @inheritDoc + */ + init() { + const editor = this.editor; + + editor.editing.view.addObserver( ClickObserver ); + + /** + * The actions view displayed inside of the balloon. + * + * @member {module:link/ui/linkactionsview~LinkActionsView} + */ + this.actionsView = this._createActionsView(); + + /** + * The form view displayed inside the balloon. + * + * @member {module:link/ui/linkformview~LinkFormView} + */ + this.formView = this._createFormView(); + + /** + * The contextual balloon plugin instance. + * + * @private + * @member {module:ui/panel/balloon/contextualballoon~ContextualBalloon} + */ + this._balloon = editor.plugins.get( ContextualBalloon ); + + // Create toolbar buttons. + this._createToolbarLinkButton(); + + // Attach lifecycle actions to the the balloon. + this._enableUserBalloonInteractions(); + } + + /** + * Creates the {@link module:link/ui/linkactionsview~LinkActionsView} instance. + * + * @private + * @returns {module:link/ui/linkactionsview~LinkActionsView} The link actions view instance. + */ + _createActionsView() { + const editor = this.editor; + const actionsView = new LinkActionsView( editor.locale ); + const linkCommand = editor.commands.get( 'link' ); + const unlinkCommand = editor.commands.get( 'unlink' ); + + actionsView.bind( 'href' ).to( linkCommand, 'value' ); + actionsView.editButtonView.bind( 'isEnabled' ).to( linkCommand ); + actionsView.unlinkButtonView.bind( 'isEnabled' ).to( unlinkCommand ); + + // Execute unlink command after clicking on the "Edit" button. + this.listenTo( actionsView, 'edit', () => { + this._addFormView(); + } ); + + // Execute unlink command after clicking on the "Unlink" button. + this.listenTo( actionsView, 'unlink', () => { + editor.execute( 'unlink' ); + this._hideUI(); + } ); + + // Close the panel on esc key press when the **actions have focus**. + actionsView.keystrokes.set( 'Esc', ( data, cancel ) => { + this._hideUI(); + cancel(); + } ); + + return actionsView; + } + + /** + * Creates the {@link module:link/ui/linkformview~LinkFormView} instance. + * + * @private + * @returns {module:link/ui/linkformview~LinkFormView} The link form instance. + */ + _createFormView() { + const editor = this.editor; + const formView = new LinkFormView( editor.locale ); + const linkCommand = editor.commands.get( 'link' ); + + formView.urlInputView.bind( 'value' ).to( linkCommand, 'value' ); + + // Form elements should be read-only when corresponding commands are disabled. + formView.urlInputView.bind( 'isReadOnly' ).to( linkCommand, 'isEnabled', value => !value ); + formView.saveButtonView.bind( 'isEnabled' ).to( linkCommand ); + + // Execute link command after clicking the "Save" button. + this.listenTo( formView, 'submit', () => { + editor.execute( 'link', formView.urlInputView.inputView.element.value ); + this._removeFormView(); + } ); + + // Hide the panel after clicking the "Cancel" button. + this.listenTo( formView, 'cancel', () => { + this._removeFormView(); + } ); + + // Close the panel on esc key press when the **form has focus**. + formView.keystrokes.set( 'Esc', ( data, cancel ) => { + this._removeFormView(); + cancel(); + } ); + + return formView; + } + + /** + * Creates a toolbar Link button. Clicking this button will show + * a {@link #_balloon} attached to the selection. + * + * @private + */ + _createToolbarLinkButton() { + const editor = this.editor; + const linkCommand = editor.commands.get( 'link' ); + const t = editor.t; + + // Handle the `Ctrl+K` keystroke and show the panel. + editor.keystrokes.set( linkKeystroke, ( keyEvtData, cancel ) => { + // Prevent focusing the search bar in FF and opening new tab in Edge. #153, #154. + cancel(); + + if ( linkCommand.isEnabled ) { + this._showUI(); + } + } ); + + editor.ui.componentFactory.add( 'link', locale => { + const button = new ButtonView( locale ); + + button.isEnabled = true; + button.label = t( 'Link' ); + button.icon = linkIcon; + button.keystroke = linkKeystroke; + button.tooltip = true; + + // Bind button to the command. + button.bind( 'isEnabled' ).to( linkCommand, 'isEnabled' ); + + // Show the panel on button click. + this.listenTo( button, 'execute', () => this._showUI() ); + + return button; + } ); + } + + /** + * Attaches actions that control whether the balloon panel containing the + * {@link #formView} is visible or not. + * + * @private + */ + _enableUserBalloonInteractions() { + const viewDocument = this.editor.editing.view; + + // Handle click on view document and show panel when selection is placed inside the link element. + // Keep panel open until selection will be inside the same link element. + this.listenTo( viewDocument, 'click', () => { + const parentLink = this._getSelectedLinkElement(); + + if ( parentLink ) { + // Then show panel but keep focus inside editor editable. + this._showUI(); + } + } ); + + // Focus the form if the balloon is visible and the Tab key has been pressed. + this.editor.keystrokes.set( 'Tab', ( data, cancel ) => { + if ( this._areActionsVisible && !this.actionsView.focusTracker.isFocused ) { + this.actionsView.focus(); + cancel(); + } + }, { + // Use the high priority because the link UI navigation is more important + // than other feature's actions, e.g. list indentation. + // https://github.com/ckeditor/ckeditor5-link/issues/146 + priority: 'high' + } ); + + // Close the panel on the Esc key press when the editable has focus and the balloon is visible. + this.editor.keystrokes.set( 'Esc', ( data, cancel ) => { + if ( this._isUIVisible ) { + this._hideUI(); + cancel(); + } + } ); + + // Close on click outside of balloon panel element. + clickOutsideHandler( { + emitter: this.formView, + activator: () => this._isUIVisible, + contextElements: [ this._balloon.view.element ], + callback: () => this._hideUI() + } ); + } + + /** + * Adds the {@link #actionsView} to the {@link #_balloon}. + * + * @protected + */ + _addActionsView() { + this._balloon.add( { + view: this.actionsView, + position: this._getBalloonPositionData() + } ); + } + + /** + * Adds the {@link #formView} to the {@link #_balloon}. + * + * @protected + */ + _addFormView() { + const editor = this.editor; + const linkCommand = editor.commands.get( 'link' ); + + this._balloon.add( { + view: this.formView, + position: this._getBalloonPositionData() + } ); + + this.formView.urlInputView.select(); + + // Make sure that each time the panel shows up, the URL field remains in sync with the value of + // the command. If the user typed in the input, then canceled the balloon (`urlInputView#value` stays + // unaltered) and re-opened it without changing the value of the link command (e.g. because they + // clicked the same link), they would see the old value instead of the actual value of the command. + // https://github.com/ckeditor/ckeditor5-link/issues/78 + // https://github.com/ckeditor/ckeditor5-link/issues/123 + this.formView.urlInputView.inputView.element.value = linkCommand.value || ''; + } + + /** + * Removes the {@link #formView} from the {@link #_balloon}. + * + * @protected + */ + _removeFormView() { + if ( this._isFormInPanel ) { + this._balloon.remove( this.formView ); + + // Because the form has an input which has focus, the focus must be brought back + // to the editor. Otherwise, it would be lost. + this.editor.editing.view.focus(); + } + } + + /** + * Shows the right kind of the UI for current state of the command. It's either + * {@link #formView} or {@link #actionsView}. + * + * @private + */ + _showUI() { + const editor = this.editor; + const linkCommand = editor.commands.get( 'link' ); + + if ( !linkCommand.isEnabled || this._isUIInPanel ) { + return; + } + + // When there's no link under the selection, go straight to the editing UI. + if ( !this._getSelectedLinkElement() ) { + this._addActionsView(); + this._addFormView(); + } + // Otherwise display just the actions UI. + else { + this._addActionsView(); + } + + // Begin responding to view#render once the UI is added. + this._startUpdatingUIOnViewRender(); + } + + /** + * Removes the {@link #formView} from the {@link #_balloon}. + * + * See {@link #_addFormView}, {@link #_addActionsView}. + * + * @protected + */ + _hideUI() { + if ( !this._isUIInPanel ) { + return; + } + + const editingView = this.editor.editing.view; + + this.stopListening( editingView, 'render' ); + + // Remove form first because it's on top of the stack. + this._removeFormView(); + + // Then remove the actions view because it's beneath the form. + this._balloon.remove( this.actionsView ); + + // Make sure the focus always gets back to the editable. + editingView.focus(); + } + + /** + * Makes the UI react to the {@link module:engine/view/document~Document#event:render} in the view + * document to reposition itself as the document changes. + * + * See: {@link #_hideUI} to learn when the UI stops reacting to the `render` event. + * + * @protected + */ + _startUpdatingUIOnViewRender() { + const editor = this.editor; + const editing = editor.editing; + const editingView = editing.view; + + let prevSelectedLink = this._getSelectedLinkElement(); + let prevSelectionParent = getSelectionParent(); + + this.listenTo( editingView, 'render', () => { + const selectedLink = this._getSelectedLinkElement(); + const selectionParent = getSelectionParent(); + + // Hide the panel if: + // + // * the selection went out of the EXISTING link element. E.g. user moved the caret out + // of the link, + // * the selection went to a different parent when creating a NEW link. E.g. someone + // else modified the document. + // * the selection has expanded (e.g. displaying link actions then pressing SHIFT+Right arrow). + // + // Note: #_getSelectedLinkElement will return a link for a non-collapsed selection only + // when fully selected. + if ( ( prevSelectedLink && !selectedLink ) || + ( !prevSelectedLink && selectionParent !== prevSelectionParent ) ) { + this._hideUI(); + } + // Update the position of the panel when: + // * the selection remains in the original link element, + // * there was no link element in the first place, i.e. creating a new link + else { + // If still in a link element, simply update the position of the balloon. + // If there was no link (e.g. inserting one), the balloon must be moved + // to the new position in the editing view (a new native DOM range). + this._balloon.updatePosition( this._getBalloonPositionData() ); + } + + prevSelectedLink = selectedLink; + prevSelectionParent = selectionParent; + } ); + + function getSelectionParent() { + return editingView.selection.focus.getAncestors() + .reverse() + .find( node => node.is( 'element' ) ); + } + } + + /** + * Returns true when {@link #formView} is in the {@link #_balloon}. + * + * @readonly + * @protected + * @type {Boolean} + */ + get _isFormInPanel() { + return this._balloon.hasView( this.formView ); + } + + /** + * Returns true when {@link #actionsView} is in the {@link #_balloon}. + * + * @readonly + * @protected + * @type {Boolean} + */ + get _areActionsInPanel() { + return this._balloon.hasView( this.actionsView ); + } + + /** + * Returns true when {@link #actionsView} is in the {@link #_balloon} and it is + * currently visible. + * + * @readonly + * @protected + * @type {Boolean} + */ + get _areActionsVisible() { + return this._balloon.visibleView === this.actionsView; + } + + /** + * Returns true when {@link #actionsView} or {@link #formView} is in the {@link #_balloon}. + * + * @readonly + * @protected + * @type {Boolean} + */ + get _isUIInPanel() { + return this._isFormInPanel || this._areActionsInPanel; + } + + /** + * Returns true when {@link #actionsView} or {@link #formView} is in the {@link #_balloon} and it is + * currently visible. + * + * @readonly + * @protected + * @type {Boolean} + */ + get _isUIVisible() { + const visibleView = this._balloon.visibleView; + + return visibleView == this.formView || this._areActionsVisible; + } + + /** + * Returns positioning options for the {@link #_balloon}. They control the way the balloon is attached + * to the target element or selection. + * + * If the selection is collapsed and inside a link element, the panel will be attached to the + * entire link element. Otherwise, it will be attached to the selection. + * + * @private + * @returns {module:utils/dom/position~Options} + */ + _getBalloonPositionData() { + const viewDocument = this.editor.editing.view; + const targetLink = this._getSelectedLinkElement(); + + const target = targetLink ? + // When selection is inside link element, then attach panel to this element. + viewDocument.domConverter.mapViewToDom( targetLink ) : + // Otherwise attach panel to the selection. + viewDocument.domConverter.viewRangeToDom( viewDocument.selection.getFirstRange() ); + + return { target }; + } + + /** + * Returns the link {@link module:engine/view/attributeelement~AttributeElement} under + * the {@link module:engine/view/document~Document editing view's} selection or `null` + * if there is none. + * + * **Note**: For a non–collapsed selection the link element is only returned when **fully** + * selected and the **only** element within the selection boundaries. + * + * @private + * @returns {module:engine/view/attributeelement~AttributeElement|null} + */ + _getSelectedLinkElement() { + const selection = this.editor.editing.view.selection; + + if ( selection.isCollapsed ) { + return findLinkElementAncestor( selection.getFirstPosition() ); + } else { + // The range for fully selected link is usually anchored in adjacent text nodes. + // Trim it to get closer to the actual link element. + const range = selection.getFirstRange().getTrimmed(); + const startLink = findLinkElementAncestor( range.start ); + const endLink = findLinkElementAncestor( range.end ); + + if ( !startLink || startLink != endLink ) { + return null; + } + + // Check if the link element is fully selected. + if ( Range.createIn( startLink ).getTrimmed().isEqual( range ) ) { + return startLink; + } else { + return null; + } + } + } +} + +// Returns a link element if there's one among the ancestors of the provided `Position`. +// +// @private +// @param {module:engine/view/position~Position} View position to analyze. +// @returns {module:engine/view/attributeelement~AttributeElement|null} Link element at the position or null. +function findLinkElementAncestor( position ) { + return position.getAncestors().find( ancestor => isLinkElement( ancestor ) ); +} diff --git a/tests/link.js b/tests/link.js index 7de7fe1..e955b86 100644 --- a/tests/link.js +++ b/tests/link.js @@ -3,770 +3,16 @@ * For licensing, see LICENSE.md. */ -/* globals document, Event */ - -import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; -import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; -import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; -import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; - -import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; import Link from '../src/link'; -import LinkEngine from '../src/linkengine'; -import LinkFormView from '../src/ui/linkformview'; -import LinkActionsView from '../src/ui/linkactionsview'; -import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon'; -import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview'; - -import Range from '@ckeditor/ckeditor5-engine/src/view/range'; -import ClickObserver from '@ckeditor/ckeditor5-engine/src/view/observer/clickobserver'; - -testUtils.createSinonSandbox(); +import LinkEditing from '../src/linkediting'; +import LinkUI from '../src/linkui'; describe( 'Link', () => { - let editor, linkFeature, linkButton, balloon, formView, actionsView, editorElement; - - beforeEach( () => { - editorElement = document.createElement( 'div' ); - document.body.appendChild( editorElement ); - - return ClassicTestEditor - .create( editorElement, { - plugins: [ Link, Paragraph ] - } ) - .then( newEditor => { - editor = newEditor; - - linkFeature = editor.plugins.get( Link ); - linkButton = editor.ui.componentFactory.create( 'link' ); - balloon = editor.plugins.get( ContextualBalloon ); - formView = linkFeature.formView; - actionsView = linkFeature.actionsView; - - // There is no point to execute BalloonPanelView attachTo and pin methods so lets override it. - testUtils.sinon.stub( balloon.view, 'attachTo' ).returns( {} ); - testUtils.sinon.stub( balloon.view, 'pin' ).returns( {} ); - - formView.render(); - } ); - } ); - - afterEach( () => { - editorElement.remove(); - - return editor.destroy(); - } ); - - it( 'should be loaded', () => { - expect( linkFeature ).to.be.instanceOf( Link ); - } ); - - it( 'should load LinkEngine', () => { - expect( editor.plugins.get( LinkEngine ) ).to.be.instanceOf( LinkEngine ); - } ); - - it( 'should load ContextualBalloon', () => { - expect( editor.plugins.get( ContextualBalloon ) ).to.be.instanceOf( ContextualBalloon ); - } ); - - describe( 'init', () => { - it( 'should register click observer', () => { - expect( editor.editing.view.getObserver( ClickObserver ) ).to.be.instanceOf( ClickObserver ); - } ); - - it( 'should create #actionsView', () => { - expect( actionsView ).to.be.instanceOf( LinkActionsView ); - } ); - - it( 'should create #formView', () => { - expect( formView ).to.be.instanceOf( LinkFormView ); - } ); - - describe( 'link toolbar button', () => { - it( 'should be registered', () => { - expect( linkButton ).to.be.instanceOf( ButtonView ); - } ); - - it( 'should be bound to the link command', () => { - const command = editor.commands.get( 'link' ); - - command.isEnabled = true; - expect( linkButton.isEnabled ).to.be.true; - - command.isEnabled = false; - expect( linkButton.isEnabled ).to.be.false; - } ); - - it( 'should call #_showUI upon #execute', () => { - const spy = testUtils.sinon.stub( linkFeature, '_showUI' ).returns( {} ); - - linkButton.fire( 'execute' ); - sinon.assert.calledWithExactly( spy ); - } ); - } ); + it( 'should require LinkEditing and LinkUI', () => { + expect( Link.requires ).to.deep.equal( [ LinkEditing, LinkUI ] ); } ); - describe( '_showUI()', () => { - let balloonAddSpy; - - beforeEach( () => { - balloonAddSpy = testUtils.sinon.spy( balloon, 'add' ); - editor.editing.view.isFocused = true; - } ); - - it( 'should not work if the link command is disabled', () => { - setModelData( editor.model, 'f[o]o' ); - editor.commands.get( 'link' ).isEnabled = false; - - linkFeature._showUI(); - - expect( balloon.visibleView ).to.be.null; - } ); - - it( 'should not throw if the UI is already visible', () => { - setModelData( editor.model, 'f[o]o' ); - - linkFeature._showUI(); - - expect( () => { - linkFeature._showUI(); - } ).to.not.throw(); - } ); - - it( 'should add #formView to the balloon and attach the balloon to the selection when text fragment is selected', () => { - setModelData( editor.model, 'f[o]o' ); - const selectedRange = editorElement.ownerDocument.getSelection().getRangeAt( 0 ); - - linkFeature._showUI(); - - expect( balloon.visibleView ).to.equal( formView ); - sinon.assert.calledWithExactly( balloonAddSpy, { - view: formView, - position: { - target: selectedRange - } - } ); - } ); - - it( 'should add #formView to the balloon and attach the balloon to the selection when selection is collapsed', () => { - setModelData( editor.model, 'f[]oo' ); - const selectedRange = editorElement.ownerDocument.getSelection().getRangeAt( 0 ); - - linkFeature._showUI(); - - expect( balloon.visibleView ).to.equal( formView ); - sinon.assert.calledWithExactly( balloonAddSpy, { - view: formView, - position: { - target: selectedRange - } - } ); - } ); - - it( 'should add #actionsView to the balloon and attach the balloon to the link element when collapsed selection is inside ' + - 'that link', - () => { - setModelData( editor.model, '<$text linkHref="url">f[]oo' ); - const linkElement = editor.editing.view.getDomRoot().querySelector( 'a' ); - - linkFeature._showUI(); - - expect( balloon.visibleView ).to.equal( actionsView ); - sinon.assert.calledWithExactly( balloonAddSpy, { - view: actionsView, - position: { - target: linkElement - } - } ); - } ); - - it( 'should disable #formView and #actionsView elements when link and unlink commands are disabled', () => { - setModelData( editor.model, 'f[o]o' ); - - linkFeature._showUI(); - - editor.commands.get( 'link' ).isEnabled = true; - editor.commands.get( 'unlink' ).isEnabled = true; - - expect( formView.urlInputView.isReadOnly ).to.be.false; - expect( formView.saveButtonView.isEnabled ).to.be.true; - expect( formView.cancelButtonView.isEnabled ).to.be.true; - - expect( actionsView.unlinkButtonView.isEnabled ).to.be.true; - expect( actionsView.editButtonView.isEnabled ).to.be.true; - - editor.commands.get( 'link' ).isEnabled = false; - editor.commands.get( 'unlink' ).isEnabled = false; - - expect( formView.urlInputView.isReadOnly ).to.be.true; - expect( formView.saveButtonView.isEnabled ).to.be.false; - expect( formView.cancelButtonView.isEnabled ).to.be.true; - - expect( actionsView.unlinkButtonView.isEnabled ).to.be.false; - expect( actionsView.editButtonView.isEnabled ).to.be.false; - } ); - - // https://github.com/ckeditor/ckeditor5-link/issues/78 - it( 'should make sure the URL input in the #formView always stays in sync with the value of the command (selected link)', () => { - setModelData( editor.model, '<$text linkHref="url">f[]oo' ); - - // Mock some leftover value **in DOM**, e.g. after previous editing. - formView.urlInputView.inputView.element.value = 'leftover'; - - linkFeature._showUI(); - actionsView.fire( 'edit' ); - - expect( formView.urlInputView.inputView.element.value ).to.equal( 'url' ); - } ); - - // https://github.com/ckeditor/ckeditor5-link/issues/123 - it( 'should make sure the URL input in the #formView always stays in sync with the value of the command (no link selected)', () => { - setModelData( editor.model, 'f[]oo' ); - - linkFeature._showUI(); - expect( formView.urlInputView.inputView.element.value ).to.equal( '' ); - } ); - - describe( 'response to view#render', () => { - it( 'should not duplicate #render listeners', () => { - const viewDocument = editor.editing.view; - - setModelData( editor.model, 'f[]oo' ); - - const spy = testUtils.sinon.stub( balloon, 'updatePosition' ).returns( {} ); - - linkFeature._showUI(); - viewDocument.render(); - linkFeature._hideUI(); - - linkFeature._showUI(); - viewDocument.render(); - sinon.assert.calledTwice( spy ); - } ); - - // https://github.com/ckeditor/ckeditor5-link/issues/113 - it( 'updates the position of the panel – editing a link, then the selection remains in the link', () => { - const viewDocument = editor.editing.view; - - setModelData( editor.model, '<$text linkHref="url">f[]oo' ); - - linkFeature._showUI(); - const spy = testUtils.sinon.stub( balloon, 'updatePosition' ).returns( {} ); - - const root = viewDocument.getRoot(); - const text = root.getChild( 0 ).getChild( 0 ).getChild( 0 ); - - // Move selection to foo[]. - viewDocument.selection.setTo( Range.createFromParentsAndOffsets( text, 3, text, 3 ), true ); - viewDocument.render(); - - sinon.assert.calledOnce( spy ); - sinon.assert.calledWithExactly( spy, { - target: viewDocument.domConverter.mapViewToDom( root.getChild( 0 ).getChild( 0 ) ) - } ); - } ); - - // https://github.com/ckeditor/ckeditor5-link/issues/113 - it( 'updates the position of the panel – creating a new link, then the selection moved', () => { - const viewDocument = editor.editing.view; - - setModelData( editor.model, 'f[]oo' ); - - linkFeature._showUI(); - const spy = testUtils.sinon.stub( balloon, 'updatePosition' ).returns( {} ); - - // Fires #render. - const root = viewDocument.getRoot(); - const text = root.getChild( 0 ).getChild( 0 ); - - viewDocument.selection.setTo( Range.createFromParentsAndOffsets( text, 3, text, 3 ), true ); - viewDocument.render(); - - sinon.assert.calledOnce( spy ); - sinon.assert.calledWithExactly( spy, { - target: editorElement.ownerDocument.getSelection().getRangeAt( 0 ) - } ); - } ); - - // https://github.com/ckeditor/ckeditor5-link/issues/113 - it( 'hides of the panel – editing a link, then the selection moved out of the link', () => { - const viewDocument = editor.editing.view; - - setModelData( editor.model, '<$text linkHref="url">f[]oobar' ); - - linkFeature._showUI(); - - const spyUpdate = testUtils.sinon.stub( balloon, 'updatePosition' ).returns( {} ); - const spyHide = testUtils.sinon.spy( linkFeature, '_hideUI' ); - - const root = viewDocument.getRoot(); - const text = root.getChild( 0 ).getChild( 1 ); - - // Move selection to b[]ar. - viewDocument.selection.setTo( Range.createFromParentsAndOffsets( text, 1, text, 1 ), true ); - viewDocument.render(); - - sinon.assert.calledOnce( spyHide ); - sinon.assert.notCalled( spyUpdate ); - } ); - - // https://github.com/ckeditor/ckeditor5-link/issues/113 - it( 'hides the panel – editing a link, then the selection expands', () => { - const viewDocument = editor.editing.view; - - setModelData( editor.model, '<$text linkHref="url">f[]oo' ); - - linkFeature._showUI(); - - const spyUpdate = testUtils.sinon.stub( balloon, 'updatePosition' ).returns( {} ); - const spyHide = testUtils.sinon.spy( linkFeature, '_hideUI' ); - - const root = viewDocument.getRoot(); - const text = root.getChild( 0 ).getChild( 0 ).getChild( 0 ); - - // Move selection to f[o]o. - viewDocument.selection.setTo( Range.createFromParentsAndOffsets( text, 1, text, 2 ), true ); - viewDocument.render(); - - sinon.assert.calledOnce( spyHide ); - sinon.assert.notCalled( spyUpdate ); - } ); - - // https://github.com/ckeditor/ckeditor5-link/issues/113 - it( 'hides the panel – creating a new link, then the selection moved to another parent', () => { - const viewDocument = editor.editing.view; - - setModelData( editor.model, 'f[]oobar' ); - - linkFeature._showUI(); - - const spyUpdate = testUtils.sinon.stub( balloon, 'updatePosition' ).returns( {} ); - const spyHide = testUtils.sinon.spy( linkFeature, '_hideUI' ); - - // Fires #render. - const root = viewDocument.getRoot(); - const text = root.getChild( 1 ).getChild( 0 ); - - // Move selection to f[o]o. - viewDocument.selection.setTo( Range.createFromParentsAndOffsets( text, 1, text, 2 ), true ); - viewDocument.render(); - - sinon.assert.calledOnce( spyHide ); - sinon.assert.notCalled( spyUpdate ); - } ); - } ); - } ); - - describe( '_hideUI()', () => { - beforeEach( () => { - linkFeature._showUI(); - } ); - - it( 'should remove the UI from the balloon', () => { - expect( balloon.hasView( formView ) ).to.be.true; - expect( balloon.hasView( actionsView ) ).to.be.true; - - linkFeature._hideUI(); - - expect( balloon.hasView( formView ) ).to.be.false; - expect( balloon.hasView( actionsView ) ).to.be.false; - } ); - - it( 'should focus the `editable` by default', () => { - const spy = testUtils.sinon.spy( editor.editing.view, 'focus' ); - - linkFeature._hideUI(); - - // First call is from _removeFormView. - sinon.assert.calledTwice( spy ); - } ); - - it( 'should not throw an error when views are not in the `balloon`', () => { - linkFeature._hideUI(); - - expect( () => { - linkFeature._hideUI(); - } ).to.not.throw(); - } ); - - it( 'should clear #render listener from the ViewDocument', () => { - const spy = sinon.spy(); - - linkFeature.listenTo( editor.editing.view, 'render', spy ); - linkFeature._hideUI(); - editor.editing.view.render(); - - sinon.assert.notCalled( spy ); - } ); - } ); - - describe( 'keyboard support', () => { - it( 'should show the UI on Ctrl+K keystroke', () => { - const spy = testUtils.sinon.stub( linkFeature, '_showUI' ).returns( {} ); - const command = editor.commands.get( 'link' ); - - command.isEnabled = false; - - editor.keystrokes.press( { - keyCode: keyCodes.k, - ctrlKey: true, - preventDefault: sinon.spy(), - stopPropagation: sinon.spy() - } ); - sinon.assert.notCalled( spy ); - - command.isEnabled = true; - - editor.keystrokes.press( { - keyCode: keyCodes.k, - ctrlKey: true, - preventDefault: sinon.spy(), - stopPropagation: sinon.spy() - } ); - sinon.assert.calledWithExactly( spy ); - } ); - - it( 'should prevent default action on Ctrl+K keystroke', () => { - const preventDefaultSpy = sinon.spy(); - const stopPropagationSpy = sinon.spy(); - - editor.keystrokes.press( { - keyCode: keyCodes.k, - ctrlKey: true, - preventDefault: preventDefaultSpy, - stopPropagation: stopPropagationSpy - } ); - - sinon.assert.calledOnce( preventDefaultSpy ); - sinon.assert.calledOnce( stopPropagationSpy ); - } ); - - it( 'should focus the the #actionsView on `Tab` key press when #actionsView is visible', () => { - const keyEvtData = { - keyCode: keyCodes.tab, - preventDefault: sinon.spy(), - stopPropagation: sinon.spy() - }; - - const normalPriorityTabCallbackSpy = sinon.spy(); - const highestPriorityTabCallbackSpy = sinon.spy(); - editor.keystrokes.set( 'Tab', normalPriorityTabCallbackSpy ); - editor.keystrokes.set( 'Tab', highestPriorityTabCallbackSpy, { priority: 'highest' } ); - - // Balloon is invisible, form not focused. - actionsView.focusTracker.isFocused = false; - - const spy = sinon.spy( actionsView, 'focus' ); - - editor.keystrokes.press( keyEvtData ); - sinon.assert.notCalled( keyEvtData.preventDefault ); - sinon.assert.notCalled( keyEvtData.stopPropagation ); - sinon.assert.notCalled( spy ); - sinon.assert.calledOnce( normalPriorityTabCallbackSpy ); - sinon.assert.calledOnce( highestPriorityTabCallbackSpy ); - - // Balloon is visible, form focused. - linkFeature._showUI(); - testUtils.sinon.stub( linkFeature, '_areActionsVisible' ).value( true ); - - actionsView.focusTracker.isFocused = true; - - editor.keystrokes.press( keyEvtData ); - sinon.assert.notCalled( keyEvtData.preventDefault ); - sinon.assert.notCalled( keyEvtData.stopPropagation ); - sinon.assert.notCalled( spy ); - sinon.assert.calledTwice( normalPriorityTabCallbackSpy ); - sinon.assert.calledTwice( highestPriorityTabCallbackSpy ); - - // Balloon is still visible, form not focused. - actionsView.focusTracker.isFocused = false; - - editor.keystrokes.press( keyEvtData ); - sinon.assert.calledOnce( keyEvtData.preventDefault ); - sinon.assert.calledOnce( keyEvtData.stopPropagation ); - sinon.assert.calledOnce( spy ); - sinon.assert.calledTwice( normalPriorityTabCallbackSpy ); - sinon.assert.calledThrice( highestPriorityTabCallbackSpy ); - } ); - - it( 'should hide the UI after Esc key press (from editor) and not focus the editable', () => { - const spy = testUtils.sinon.spy( linkFeature, '_hideUI' ); - const keyEvtData = { - keyCode: keyCodes.esc, - preventDefault: sinon.spy(), - stopPropagation: sinon.spy() - }; - - // Balloon is visible. - linkFeature._showUI(); - editor.keystrokes.press( keyEvtData ); - - sinon.assert.calledWithExactly( spy ); - } ); - - it( 'should not hide the UI after Esc key press (from editor) when UI is open but is not visible', () => { - const spy = testUtils.sinon.spy( linkFeature, '_hideUI' ); - const keyEvtData = { - keyCode: keyCodes.esc, - preventDefault: () => {}, - stopPropagation: () => {} - }; - - const viewMock = { - ready: true, - render: () => {}, - destroy: () => {} - }; - - linkFeature._showUI(); - - // Some view precedes the link UI in the balloon. - balloon.add( { view: viewMock } ); - editor.keystrokes.press( keyEvtData ); - - sinon.assert.notCalled( spy ); - } ); - } ); - - describe( 'mouse support', () => { - it( 'should hide the UI and not focus editable upon clicking outside the UI', () => { - const spy = testUtils.sinon.spy( linkFeature, '_hideUI' ); - - linkFeature._showUI( true ); - document.body.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) ); - - sinon.assert.calledWithExactly( spy ); - } ); - - it( 'should not hide the UI upon clicking inside the the UI', () => { - const spy = testUtils.sinon.spy( linkFeature, '_hideUI' ); - - linkFeature._showUI( true ); - balloon.view.element.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) ); - - sinon.assert.notCalled( spy ); - } ); - - describe( 'clicking on editable', () => { - let observer, spy; - - beforeEach( () => { - observer = editor.editing.view.getObserver( ClickObserver ); - editor.model.schema.extend( '$text', { allowIn: '$root' } ); - - spy = testUtils.sinon.stub( linkFeature, '_showUI' ).returns( {} ); - } ); - - it( 'should show the UI when collapsed selection is inside link element', () => { - setModelData( editor.model, '<$text linkHref="url">fo[]o' ); - - observer.fire( 'click', { target: document.body } ); - sinon.assert.calledWithExactly( spy ); - } ); - - it( 'should show the UI when selection exclusively encloses a link element (#1)', () => { - setModelData( editor.model, '[<$text linkHref="url">foo]' ); - - observer.fire( 'click', { target: {} } ); - sinon.assert.calledWithExactly( spy ); - } ); - - it( 'should show the UI when selection exclusively encloses a link element (#2)', () => { - setModelData( editor.model, '<$text linkHref="url">[foo]' ); - - observer.fire( 'click', { target: {} } ); - sinon.assert.calledWithExactly( spy ); - } ); - - it( 'should do nothing when selection is not inside link element', () => { - setModelData( editor.model, '[]' ); - - observer.fire( 'click', { target: {} } ); - sinon.assert.notCalled( spy ); - } ); - - it( 'should do nothing when selection is non-collapsed and doesn\'t enclose a link element (#1)', () => { - setModelData( editor.model, '<$text linkHref="url">f[o]o' ); - - observer.fire( 'click', { target: {} } ); - sinon.assert.notCalled( spy ); - } ); - - it( 'should do nothing when selection is non-collapsed and doesn\'t enclose a link element (#2)', () => { - setModelData( editor.model, '<$text linkHref="url">[fo]o' ); - - observer.fire( 'click', { target: {} } ); - sinon.assert.notCalled( spy ); - } ); - - it( 'should do nothing when selection is non-collapsed and doesn\'t enclose a link element (#3)', () => { - setModelData( editor.model, '<$text linkHref="url">f[oo]' ); - - observer.fire( 'click', { target: {} } ); - sinon.assert.notCalled( spy ); - } ); - - it( 'should do nothing when selection is non-collapsed and doesn\'t enclose a link element (#4)', () => { - setModelData( editor.model, 'ba[r<$text linkHref="url">foo]' ); - - observer.fire( 'click', { target: {} } ); - sinon.assert.notCalled( spy ); - } ); - - it( 'should do nothing when selection is non-collapsed and doesn\'t enclose a link element (#5)', () => { - setModelData( editor.model, 'ba[r<$text linkHref="url">foo]' ); - - observer.fire( 'click', { target: {} } ); - sinon.assert.notCalled( spy ); - } ); - } ); - } ); - - describe( 'actions view', () => { - let focusEditableSpy; - - beforeEach( () => { - focusEditableSpy = testUtils.sinon.spy( editor.editing.view, 'focus' ); - } ); - - it( 'should mark the editor UI as focused when the #actionsView is focused', () => { - linkFeature._showUI(); - linkFeature._removeFormView(); - - expect( balloon.visibleView ).to.equal( actionsView ); - - editor.ui.focusTracker.isFocused = false; - actionsView.element.dispatchEvent( new Event( 'focus' ) ); - - expect( editor.ui.focusTracker.isFocused ).to.be.true; - } ); - - describe( 'binding', () => { - it( 'should show the #formView on #edit event and select the URL input field', () => { - linkFeature._showUI(); - linkFeature._removeFormView(); - - const selectSpy = testUtils.sinon.spy( formView.urlInputView, 'select' ); - actionsView.fire( 'edit' ); - - expect( balloon.visibleView ).to.equal( formView ); - sinon.assert.calledOnce( selectSpy ); - } ); - - it( 'should execute unlink command on actionsView#unlink event', () => { - const executeSpy = testUtils.sinon.spy( editor, 'execute' ); - - actionsView.fire( 'unlink' ); - - expect( executeSpy.calledOnce ).to.be.true; - expect( executeSpy.calledWithExactly( 'unlink' ) ).to.be.true; - } ); - - it( 'should hide and focus editable on actionsView#unlink event', () => { - linkFeature._showUI(); - linkFeature._removeFormView(); - - // Removing the form would call the focus spy. - focusEditableSpy.resetHistory(); - actionsView.fire( 'unlink' ); - - expect( balloon.visibleView ).to.be.null; - expect( focusEditableSpy.calledOnce ).to.be.true; - } ); - - it( 'should hide after Esc key press', () => { - const keyEvtData = { - keyCode: keyCodes.esc, - preventDefault: sinon.spy(), - stopPropagation: sinon.spy() - }; - - linkFeature._showUI(); - linkFeature._removeFormView(); - - // Removing the form would call the focus spy. - focusEditableSpy.resetHistory(); - - actionsView.keystrokes.press( keyEvtData ); - expect( balloon.visibleView ).to.equal( null ); - expect( focusEditableSpy.calledOnce ).to.be.true; - } ); - } ); - } ); - - describe( 'link form view', () => { - let focusEditableSpy; - - beforeEach( () => { - focusEditableSpy = testUtils.sinon.spy( editor.editing.view, 'focus' ); - } ); - - it( 'should mark the editor UI as focused when the #formView is focused', () => { - linkFeature._showUI(); - expect( balloon.visibleView ).to.equal( formView ); - - editor.ui.focusTracker.isFocused = false; - formView.element.dispatchEvent( new Event( 'focus' ) ); - - expect( editor.ui.focusTracker.isFocused ).to.be.true; - } ); - - describe( 'binding', () => { - beforeEach( () => { - setModelData( editor.model, 'f[o]o' ); - } ); - - it( 'should bind formView.urlInputView#value to link command value', () => { - const command = editor.commands.get( 'link' ); - - expect( formView.urlInputView.value ).to.undefined; - - command.value = 'http://cksource.com'; - expect( formView.urlInputView.value ).to.equal( 'http://cksource.com' ); - } ); - - it( 'should execute link command on formView#submit event', () => { - const executeSpy = testUtils.sinon.spy( editor, 'execute' ); - - formView.urlInputView.value = 'http://ckeditor.com'; - expect( formView.urlInputView.inputView.element.value ).to.equal( 'http://ckeditor.com' ); - - formView.urlInputView.inputView.element.value = 'http://cksource.com'; - formView.fire( 'submit' ); - - expect( executeSpy.calledOnce ).to.be.true; - expect( executeSpy.calledWithExactly( 'link', 'http://cksource.com' ) ).to.be.true; - } ); - - it( 'should hide and reveal the #actionsView on formView#submit event', () => { - linkFeature._showUI(); - formView.fire( 'submit' ); - - expect( balloon.visibleView ).to.equal( actionsView ); - expect( focusEditableSpy.calledOnce ).to.be.true; - } ); - - it( 'should hide and reveal the #actionsView on formView#cancel event', () => { - linkFeature._showUI(); - formView.fire( 'cancel' ); - - expect( balloon.visibleView ).to.equal( actionsView ); - expect( focusEditableSpy.calledOnce ).to.be.true; - } ); - - it( 'should hide after Esc key press', () => { - const keyEvtData = { - keyCode: keyCodes.esc, - preventDefault: sinon.spy(), - stopPropagation: sinon.spy() - }; - - linkFeature._showUI(); - - formView.keystrokes.press( keyEvtData ); - expect( balloon.visibleView ).to.equal( actionsView ); - expect( focusEditableSpy.calledOnce ).to.be.true; - } ); - } ); + it( 'should be named', () => { + expect( Link.pluginName ).to.equal( 'Link' ); } ); } ); diff --git a/tests/linkengine.js b/tests/linkediting.js similarity index 95% rename from tests/linkengine.js rename to tests/linkediting.js index 42498bc..104dd10 100644 --- a/tests/linkengine.js +++ b/tests/linkediting.js @@ -3,7 +3,7 @@ * For licensing, see LICENSE.md. */ -import LinkEngine from '../src/linkengine'; +import LinkEditing from '../src/linkediting'; import LinkCommand from '../src/linkcommand'; import UnlinkCommand from '../src/unlinkcommand'; @@ -15,13 +15,13 @@ import { isLinkElement } from '../src/utils'; import { downcastAttributeToElement } from '@ckeditor/ckeditor5-engine/src/conversion/downcast-converters'; -describe( 'LinkEngine', () => { +describe( 'LinkEditing', () => { let editor, model; beforeEach( () => { return VirtualTestEditor .create( { - plugins: [ Paragraph, LinkEngine ] + plugins: [ Paragraph, LinkEditing ] } ) .then( newEditor => { editor = newEditor; @@ -30,7 +30,7 @@ describe( 'LinkEngine', () => { } ); it( 'should be loaded', () => { - expect( editor.plugins.get( LinkEngine ) ).to.be.instanceOf( LinkEngine ); + expect( editor.plugins.get( LinkEditing ) ).to.be.instanceOf( LinkEditing ); } ); it( 'should set proper schema rules', () => { diff --git a/tests/linkui.js b/tests/linkui.js new file mode 100644 index 0000000..7d062dc --- /dev/null +++ b/tests/linkui.js @@ -0,0 +1,764 @@ +/** + * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/* globals document, Event */ + +import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; +import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; +import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; + +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import LinkEditing from '../src/linkediting'; +import LinkUI from '../src/linkui'; +import LinkFormView from '../src/ui/linkformview'; +import LinkActionsView from '../src/ui/linkactionsview'; +import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon'; +import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview'; + +import Range from '@ckeditor/ckeditor5-engine/src/view/range'; +import ClickObserver from '@ckeditor/ckeditor5-engine/src/view/observer/clickobserver'; + +testUtils.createSinonSandbox(); + +describe( 'LinkUI', () => { + let editor, linkUIFeature, linkButton, balloon, formView, actionsView, editorElement; + + beforeEach( () => { + editorElement = document.createElement( 'div' ); + document.body.appendChild( editorElement ); + + return ClassicTestEditor + .create( editorElement, { + plugins: [ LinkEditing, LinkUI, Paragraph ] + } ) + .then( newEditor => { + editor = newEditor; + + linkUIFeature = editor.plugins.get( LinkUI ); + linkButton = editor.ui.componentFactory.create( 'link' ); + balloon = editor.plugins.get( ContextualBalloon ); + formView = linkUIFeature.formView; + actionsView = linkUIFeature.actionsView; + + // There is no point to execute BalloonPanelView attachTo and pin methods so lets override it. + testUtils.sinon.stub( balloon.view, 'attachTo' ).returns( {} ); + testUtils.sinon.stub( balloon.view, 'pin' ).returns( {} ); + + formView.render(); + } ); + } ); + + afterEach( () => { + editorElement.remove(); + + return editor.destroy(); + } ); + + it( 'should load ContextualBalloon', () => { + expect( editor.plugins.get( ContextualBalloon ) ).to.be.instanceOf( ContextualBalloon ); + } ); + + describe( 'init', () => { + it( 'should register click observer', () => { + expect( editor.editing.view.getObserver( ClickObserver ) ).to.be.instanceOf( ClickObserver ); + } ); + + it( 'should create #actionsView', () => { + expect( actionsView ).to.be.instanceOf( LinkActionsView ); + } ); + + it( 'should create #formView', () => { + expect( formView ).to.be.instanceOf( LinkFormView ); + } ); + + describe( 'link toolbar button', () => { + it( 'should be registered', () => { + expect( linkButton ).to.be.instanceOf( ButtonView ); + } ); + + it( 'should be bound to the link command', () => { + const command = editor.commands.get( 'link' ); + + command.isEnabled = true; + expect( linkButton.isEnabled ).to.be.true; + + command.isEnabled = false; + expect( linkButton.isEnabled ).to.be.false; + } ); + + it( 'should call #_showUI upon #execute', () => { + const spy = testUtils.sinon.stub( linkUIFeature, '_showUI' ).returns( {} ); + + linkButton.fire( 'execute' ); + sinon.assert.calledWithExactly( spy ); + } ); + } ); + } ); + + describe( '_showUI()', () => { + let balloonAddSpy; + + beforeEach( () => { + balloonAddSpy = testUtils.sinon.spy( balloon, 'add' ); + editor.editing.view.isFocused = true; + } ); + + it( 'should not work if the link command is disabled', () => { + setModelData( editor.model, 'f[o]o' ); + editor.commands.get( 'link' ).isEnabled = false; + + linkUIFeature._showUI(); + + expect( balloon.visibleView ).to.be.null; + } ); + + it( 'should not throw if the UI is already visible', () => { + setModelData( editor.model, 'f[o]o' ); + + linkUIFeature._showUI(); + + expect( () => { + linkUIFeature._showUI(); + } ).to.not.throw(); + } ); + + it( 'should add #formView to the balloon and attach the balloon to the selection when text fragment is selected', () => { + setModelData( editor.model, 'f[o]o' ); + const selectedRange = editorElement.ownerDocument.getSelection().getRangeAt( 0 ); + + linkUIFeature._showUI(); + + expect( balloon.visibleView ).to.equal( formView ); + sinon.assert.calledWithExactly( balloonAddSpy, { + view: formView, + position: { + target: selectedRange + } + } ); + } ); + + it( 'should add #formView to the balloon and attach the balloon to the selection when selection is collapsed', () => { + setModelData( editor.model, 'f[]oo' ); + const selectedRange = editorElement.ownerDocument.getSelection().getRangeAt( 0 ); + + linkUIFeature._showUI(); + + expect( balloon.visibleView ).to.equal( formView ); + sinon.assert.calledWithExactly( balloonAddSpy, { + view: formView, + position: { + target: selectedRange + } + } ); + } ); + + it( 'should add #actionsView to the balloon and attach the balloon to the link element when collapsed selection is inside ' + + 'that link', + () => { + setModelData( editor.model, '<$text linkHref="url">f[]oo' ); + const linkElement = editor.editing.view.getDomRoot().querySelector( 'a' ); + + linkUIFeature._showUI(); + + expect( balloon.visibleView ).to.equal( actionsView ); + sinon.assert.calledWithExactly( balloonAddSpy, { + view: actionsView, + position: { + target: linkElement + } + } ); + } ); + + it( 'should disable #formView and #actionsView elements when link and unlink commands are disabled', () => { + setModelData( editor.model, 'f[o]o' ); + + linkUIFeature._showUI(); + + editor.commands.get( 'link' ).isEnabled = true; + editor.commands.get( 'unlink' ).isEnabled = true; + + expect( formView.urlInputView.isReadOnly ).to.be.false; + expect( formView.saveButtonView.isEnabled ).to.be.true; + expect( formView.cancelButtonView.isEnabled ).to.be.true; + + expect( actionsView.unlinkButtonView.isEnabled ).to.be.true; + expect( actionsView.editButtonView.isEnabled ).to.be.true; + + editor.commands.get( 'link' ).isEnabled = false; + editor.commands.get( 'unlink' ).isEnabled = false; + + expect( formView.urlInputView.isReadOnly ).to.be.true; + expect( formView.saveButtonView.isEnabled ).to.be.false; + expect( formView.cancelButtonView.isEnabled ).to.be.true; + + expect( actionsView.unlinkButtonView.isEnabled ).to.be.false; + expect( actionsView.editButtonView.isEnabled ).to.be.false; + } ); + + // https://github.com/ckeditor/ckeditor5-link/issues/78 + it( 'should make sure the URL input in the #formView always stays in sync with the value of the command (selected link)', () => { + setModelData( editor.model, '<$text linkHref="url">f[]oo' ); + + // Mock some leftover value **in DOM**, e.g. after previous editing. + formView.urlInputView.inputView.element.value = 'leftover'; + + linkUIFeature._showUI(); + actionsView.fire( 'edit' ); + + expect( formView.urlInputView.inputView.element.value ).to.equal( 'url' ); + } ); + + // https://github.com/ckeditor/ckeditor5-link/issues/123 + it( 'should make sure the URL input in the #formView always stays in sync with the value of the command (no link selected)', () => { + setModelData( editor.model, 'f[]oo' ); + + linkUIFeature._showUI(); + expect( formView.urlInputView.inputView.element.value ).to.equal( '' ); + } ); + + describe( 'response to view#render', () => { + it( 'should not duplicate #render listeners', () => { + const viewDocument = editor.editing.view; + + setModelData( editor.model, 'f[]oo' ); + + const spy = testUtils.sinon.stub( balloon, 'updatePosition' ).returns( {} ); + + linkUIFeature._showUI(); + viewDocument.render(); + linkUIFeature._hideUI(); + + linkUIFeature._showUI(); + viewDocument.render(); + sinon.assert.calledTwice( spy ); + } ); + + // https://github.com/ckeditor/ckeditor5-link/issues/113 + it( 'updates the position of the panel – editing a link, then the selection remains in the link', () => { + const viewDocument = editor.editing.view; + + setModelData( editor.model, '<$text linkHref="url">f[]oo' ); + + linkUIFeature._showUI(); + const spy = testUtils.sinon.stub( balloon, 'updatePosition' ).returns( {} ); + + const root = viewDocument.getRoot(); + const text = root.getChild( 0 ).getChild( 0 ).getChild( 0 ); + + // Move selection to foo[]. + viewDocument.selection.setTo( Range.createFromParentsAndOffsets( text, 3, text, 3 ), true ); + viewDocument.render(); + + sinon.assert.calledOnce( spy ); + sinon.assert.calledWithExactly( spy, { + target: viewDocument.domConverter.mapViewToDom( root.getChild( 0 ).getChild( 0 ) ) + } ); + } ); + + // https://github.com/ckeditor/ckeditor5-link/issues/113 + it( 'updates the position of the panel – creating a new link, then the selection moved', () => { + const viewDocument = editor.editing.view; + + setModelData( editor.model, 'f[]oo' ); + + linkUIFeature._showUI(); + const spy = testUtils.sinon.stub( balloon, 'updatePosition' ).returns( {} ); + + // Fires #render. + const root = viewDocument.getRoot(); + const text = root.getChild( 0 ).getChild( 0 ); + + viewDocument.selection.setTo( Range.createFromParentsAndOffsets( text, 3, text, 3 ), true ); + viewDocument.render(); + + sinon.assert.calledOnce( spy ); + sinon.assert.calledWithExactly( spy, { + target: editorElement.ownerDocument.getSelection().getRangeAt( 0 ) + } ); + } ); + + // https://github.com/ckeditor/ckeditor5-link/issues/113 + it( 'hides of the panel – editing a link, then the selection moved out of the link', () => { + const viewDocument = editor.editing.view; + + setModelData( editor.model, '<$text linkHref="url">f[]oobar' ); + + linkUIFeature._showUI(); + + const spyUpdate = testUtils.sinon.stub( balloon, 'updatePosition' ).returns( {} ); + const spyHide = testUtils.sinon.spy( linkUIFeature, '_hideUI' ); + + const root = viewDocument.getRoot(); + const text = root.getChild( 0 ).getChild( 1 ); + + // Move selection to b[]ar. + viewDocument.selection.setTo( Range.createFromParentsAndOffsets( text, 1, text, 1 ), true ); + viewDocument.render(); + + sinon.assert.calledOnce( spyHide ); + sinon.assert.notCalled( spyUpdate ); + } ); + + // https://github.com/ckeditor/ckeditor5-link/issues/113 + it( 'hides the panel – editing a link, then the selection expands', () => { + const viewDocument = editor.editing.view; + + setModelData( editor.model, '<$text linkHref="url">f[]oo' ); + + linkUIFeature._showUI(); + + const spyUpdate = testUtils.sinon.stub( balloon, 'updatePosition' ).returns( {} ); + const spyHide = testUtils.sinon.spy( linkUIFeature, '_hideUI' ); + + const root = viewDocument.getRoot(); + const text = root.getChild( 0 ).getChild( 0 ).getChild( 0 ); + + // Move selection to f[o]o. + viewDocument.selection.setTo( Range.createFromParentsAndOffsets( text, 1, text, 2 ), true ); + viewDocument.render(); + + sinon.assert.calledOnce( spyHide ); + sinon.assert.notCalled( spyUpdate ); + } ); + + // https://github.com/ckeditor/ckeditor5-link/issues/113 + it( 'hides the panel – creating a new link, then the selection moved to another parent', () => { + const viewDocument = editor.editing.view; + + setModelData( editor.model, 'f[]oobar' ); + + linkUIFeature._showUI(); + + const spyUpdate = testUtils.sinon.stub( balloon, 'updatePosition' ).returns( {} ); + const spyHide = testUtils.sinon.spy( linkUIFeature, '_hideUI' ); + + // Fires #render. + const root = viewDocument.getRoot(); + const text = root.getChild( 1 ).getChild( 0 ); + + // Move selection to f[o]o. + viewDocument.selection.setTo( Range.createFromParentsAndOffsets( text, 1, text, 2 ), true ); + viewDocument.render(); + + sinon.assert.calledOnce( spyHide ); + sinon.assert.notCalled( spyUpdate ); + } ); + } ); + } ); + + describe( '_hideUI()', () => { + beforeEach( () => { + linkUIFeature._showUI(); + } ); + + it( 'should remove the UI from the balloon', () => { + expect( balloon.hasView( formView ) ).to.be.true; + expect( balloon.hasView( actionsView ) ).to.be.true; + + linkUIFeature._hideUI(); + + expect( balloon.hasView( formView ) ).to.be.false; + expect( balloon.hasView( actionsView ) ).to.be.false; + } ); + + it( 'should focus the `editable` by default', () => { + const spy = testUtils.sinon.spy( editor.editing.view, 'focus' ); + + linkUIFeature._hideUI(); + + // First call is from _removeFormView. + sinon.assert.calledTwice( spy ); + } ); + + it( 'should not throw an error when views are not in the `balloon`', () => { + linkUIFeature._hideUI(); + + expect( () => { + linkUIFeature._hideUI(); + } ).to.not.throw(); + } ); + + it( 'should clear #render listener from the ViewDocument', () => { + const spy = sinon.spy(); + + linkUIFeature.listenTo( editor.editing.view, 'render', spy ); + linkUIFeature._hideUI(); + editor.editing.view.render(); + + sinon.assert.notCalled( spy ); + } ); + } ); + + describe( 'keyboard support', () => { + it( 'should show the UI on Ctrl+K keystroke', () => { + const spy = testUtils.sinon.stub( linkUIFeature, '_showUI' ).returns( {} ); + const command = editor.commands.get( 'link' ); + + command.isEnabled = false; + + editor.keystrokes.press( { + keyCode: keyCodes.k, + ctrlKey: true, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + } ); + sinon.assert.notCalled( spy ); + + command.isEnabled = true; + + editor.keystrokes.press( { + keyCode: keyCodes.k, + ctrlKey: true, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + } ); + sinon.assert.calledWithExactly( spy ); + } ); + + it( 'should prevent default action on Ctrl+K keystroke', () => { + const preventDefaultSpy = sinon.spy(); + const stopPropagationSpy = sinon.spy(); + + editor.keystrokes.press( { + keyCode: keyCodes.k, + ctrlKey: true, + preventDefault: preventDefaultSpy, + stopPropagation: stopPropagationSpy + } ); + + sinon.assert.calledOnce( preventDefaultSpy ); + sinon.assert.calledOnce( stopPropagationSpy ); + } ); + + it( 'should focus the the #actionsView on `Tab` key press when #actionsView is visible', () => { + const keyEvtData = { + keyCode: keyCodes.tab, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + const normalPriorityTabCallbackSpy = sinon.spy(); + const highestPriorityTabCallbackSpy = sinon.spy(); + editor.keystrokes.set( 'Tab', normalPriorityTabCallbackSpy ); + editor.keystrokes.set( 'Tab', highestPriorityTabCallbackSpy, { priority: 'highest' } ); + + // Balloon is invisible, form not focused. + actionsView.focusTracker.isFocused = false; + + const spy = sinon.spy( actionsView, 'focus' ); + + editor.keystrokes.press( keyEvtData ); + sinon.assert.notCalled( keyEvtData.preventDefault ); + sinon.assert.notCalled( keyEvtData.stopPropagation ); + sinon.assert.notCalled( spy ); + sinon.assert.calledOnce( normalPriorityTabCallbackSpy ); + sinon.assert.calledOnce( highestPriorityTabCallbackSpy ); + + // Balloon is visible, form focused. + linkUIFeature._showUI(); + testUtils.sinon.stub( linkUIFeature, '_areActionsVisible' ).value( true ); + + actionsView.focusTracker.isFocused = true; + + editor.keystrokes.press( keyEvtData ); + sinon.assert.notCalled( keyEvtData.preventDefault ); + sinon.assert.notCalled( keyEvtData.stopPropagation ); + sinon.assert.notCalled( spy ); + sinon.assert.calledTwice( normalPriorityTabCallbackSpy ); + sinon.assert.calledTwice( highestPriorityTabCallbackSpy ); + + // Balloon is still visible, form not focused. + actionsView.focusTracker.isFocused = false; + + editor.keystrokes.press( keyEvtData ); + sinon.assert.calledOnce( keyEvtData.preventDefault ); + sinon.assert.calledOnce( keyEvtData.stopPropagation ); + sinon.assert.calledOnce( spy ); + sinon.assert.calledTwice( normalPriorityTabCallbackSpy ); + sinon.assert.calledThrice( highestPriorityTabCallbackSpy ); + } ); + + it( 'should hide the UI after Esc key press (from editor) and not focus the editable', () => { + const spy = testUtils.sinon.spy( linkUIFeature, '_hideUI' ); + const keyEvtData = { + keyCode: keyCodes.esc, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + // Balloon is visible. + linkUIFeature._showUI(); + editor.keystrokes.press( keyEvtData ); + + sinon.assert.calledWithExactly( spy ); + } ); + + it( 'should not hide the UI after Esc key press (from editor) when UI is open but is not visible', () => { + const spy = testUtils.sinon.spy( linkUIFeature, '_hideUI' ); + const keyEvtData = { + keyCode: keyCodes.esc, + preventDefault: () => {}, + stopPropagation: () => {} + }; + + const viewMock = { + ready: true, + render: () => {}, + destroy: () => {} + }; + + linkUIFeature._showUI(); + + // Some view precedes the link UI in the balloon. + balloon.add( { view: viewMock } ); + editor.keystrokes.press( keyEvtData ); + + sinon.assert.notCalled( spy ); + } ); + } ); + + describe( 'mouse support', () => { + it( 'should hide the UI and not focus editable upon clicking outside the UI', () => { + const spy = testUtils.sinon.spy( linkUIFeature, '_hideUI' ); + + linkUIFeature._showUI( true ); + document.body.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) ); + + sinon.assert.calledWithExactly( spy ); + } ); + + it( 'should not hide the UI upon clicking inside the the UI', () => { + const spy = testUtils.sinon.spy( linkUIFeature, '_hideUI' ); + + linkUIFeature._showUI( true ); + balloon.view.element.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) ); + + sinon.assert.notCalled( spy ); + } ); + + describe( 'clicking on editable', () => { + let observer, spy; + + beforeEach( () => { + observer = editor.editing.view.getObserver( ClickObserver ); + editor.model.schema.extend( '$text', { allowIn: '$root' } ); + + spy = testUtils.sinon.stub( linkUIFeature, '_showUI' ).returns( {} ); + } ); + + it( 'should show the UI when collapsed selection is inside link element', () => { + setModelData( editor.model, '<$text linkHref="url">fo[]o' ); + + observer.fire( 'click', { target: document.body } ); + sinon.assert.calledWithExactly( spy ); + } ); + + it( 'should show the UI when selection exclusively encloses a link element (#1)', () => { + setModelData( editor.model, '[<$text linkHref="url">foo]' ); + + observer.fire( 'click', { target: {} } ); + sinon.assert.calledWithExactly( spy ); + } ); + + it( 'should show the UI when selection exclusively encloses a link element (#2)', () => { + setModelData( editor.model, '<$text linkHref="url">[foo]' ); + + observer.fire( 'click', { target: {} } ); + sinon.assert.calledWithExactly( spy ); + } ); + + it( 'should do nothing when selection is not inside link element', () => { + setModelData( editor.model, '[]' ); + + observer.fire( 'click', { target: {} } ); + sinon.assert.notCalled( spy ); + } ); + + it( 'should do nothing when selection is non-collapsed and doesn\'t enclose a link element (#1)', () => { + setModelData( editor.model, '<$text linkHref="url">f[o]o' ); + + observer.fire( 'click', { target: {} } ); + sinon.assert.notCalled( spy ); + } ); + + it( 'should do nothing when selection is non-collapsed and doesn\'t enclose a link element (#2)', () => { + setModelData( editor.model, '<$text linkHref="url">[fo]o' ); + + observer.fire( 'click', { target: {} } ); + sinon.assert.notCalled( spy ); + } ); + + it( 'should do nothing when selection is non-collapsed and doesn\'t enclose a link element (#3)', () => { + setModelData( editor.model, '<$text linkHref="url">f[oo]' ); + + observer.fire( 'click', { target: {} } ); + sinon.assert.notCalled( spy ); + } ); + + it( 'should do nothing when selection is non-collapsed and doesn\'t enclose a link element (#4)', () => { + setModelData( editor.model, 'ba[r<$text linkHref="url">foo]' ); + + observer.fire( 'click', { target: {} } ); + sinon.assert.notCalled( spy ); + } ); + + it( 'should do nothing when selection is non-collapsed and doesn\'t enclose a link element (#5)', () => { + setModelData( editor.model, 'ba[r<$text linkHref="url">foo]' ); + + observer.fire( 'click', { target: {} } ); + sinon.assert.notCalled( spy ); + } ); + } ); + } ); + + describe( 'actions view', () => { + let focusEditableSpy; + + beforeEach( () => { + focusEditableSpy = testUtils.sinon.spy( editor.editing.view, 'focus' ); + } ); + + it( 'should mark the editor UI as focused when the #actionsView is focused', () => { + linkUIFeature._showUI(); + linkUIFeature._removeFormView(); + + expect( balloon.visibleView ).to.equal( actionsView ); + + editor.ui.focusTracker.isFocused = false; + actionsView.element.dispatchEvent( new Event( 'focus' ) ); + + expect( editor.ui.focusTracker.isFocused ).to.be.true; + } ); + + describe( 'binding', () => { + it( 'should show the #formView on #edit event and select the URL input field', () => { + linkUIFeature._showUI(); + linkUIFeature._removeFormView(); + + const selectSpy = testUtils.sinon.spy( formView.urlInputView, 'select' ); + actionsView.fire( 'edit' ); + + expect( balloon.visibleView ).to.equal( formView ); + sinon.assert.calledOnce( selectSpy ); + } ); + + it( 'should execute unlink command on actionsView#unlink event', () => { + const executeSpy = testUtils.sinon.spy( editor, 'execute' ); + + actionsView.fire( 'unlink' ); + + expect( executeSpy.calledOnce ).to.be.true; + expect( executeSpy.calledWithExactly( 'unlink' ) ).to.be.true; + } ); + + it( 'should hide and focus editable on actionsView#unlink event', () => { + linkUIFeature._showUI(); + linkUIFeature._removeFormView(); + + // Removing the form would call the focus spy. + focusEditableSpy.resetHistory(); + actionsView.fire( 'unlink' ); + + expect( balloon.visibleView ).to.be.null; + expect( focusEditableSpy.calledOnce ).to.be.true; + } ); + + it( 'should hide after Esc key press', () => { + const keyEvtData = { + keyCode: keyCodes.esc, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + linkUIFeature._showUI(); + linkUIFeature._removeFormView(); + + // Removing the form would call the focus spy. + focusEditableSpy.resetHistory(); + + actionsView.keystrokes.press( keyEvtData ); + expect( balloon.visibleView ).to.equal( null ); + expect( focusEditableSpy.calledOnce ).to.be.true; + } ); + } ); + } ); + + describe( 'link form view', () => { + let focusEditableSpy; + + beforeEach( () => { + focusEditableSpy = testUtils.sinon.spy( editor.editing.view, 'focus' ); + } ); + + it( 'should mark the editor UI as focused when the #formView is focused', () => { + linkUIFeature._showUI(); + expect( balloon.visibleView ).to.equal( formView ); + + editor.ui.focusTracker.isFocused = false; + formView.element.dispatchEvent( new Event( 'focus' ) ); + + expect( editor.ui.focusTracker.isFocused ).to.be.true; + } ); + + describe( 'binding', () => { + beforeEach( () => { + setModelData( editor.model, 'f[o]o' ); + } ); + + it( 'should bind formView.urlInputView#value to link command value', () => { + const command = editor.commands.get( 'link' ); + + expect( formView.urlInputView.value ).to.undefined; + + command.value = 'http://cksource.com'; + expect( formView.urlInputView.value ).to.equal( 'http://cksource.com' ); + } ); + + it( 'should execute link command on formView#submit event', () => { + const executeSpy = testUtils.sinon.spy( editor, 'execute' ); + + formView.urlInputView.value = 'http://ckeditor.com'; + expect( formView.urlInputView.inputView.element.value ).to.equal( 'http://ckeditor.com' ); + + formView.urlInputView.inputView.element.value = 'http://cksource.com'; + formView.fire( 'submit' ); + + expect( executeSpy.calledOnce ).to.be.true; + expect( executeSpy.calledWithExactly( 'link', 'http://cksource.com' ) ).to.be.true; + } ); + + it( 'should hide and reveal the #actionsView on formView#submit event', () => { + linkUIFeature._showUI(); + formView.fire( 'submit' ); + + expect( balloon.visibleView ).to.equal( actionsView ); + expect( focusEditableSpy.calledOnce ).to.be.true; + } ); + + it( 'should hide and reveal the #actionsView on formView#cancel event', () => { + linkUIFeature._showUI(); + formView.fire( 'cancel' ); + + expect( balloon.visibleView ).to.equal( actionsView ); + expect( focusEditableSpy.calledOnce ).to.be.true; + } ); + + it( 'should hide after Esc key press', () => { + const keyEvtData = { + keyCode: keyCodes.esc, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + linkUIFeature._showUI(); + + formView.keystrokes.press( keyEvtData ); + expect( balloon.visibleView ).to.equal( actionsView ); + expect( focusEditableSpy.calledOnce ).to.be.true; + } ); + } ); + } ); +} );