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$text>' );
- 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$text>' );
-
- // 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$text>' );
-
- 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[]oo$text>bar' );
-
- 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$text>' );
-
- 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$text>' );
-
- 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$text>]' );
-
- 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]$text>' );
-
- 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$text>' );
-
- 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$text>' );
-
- 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]$text>' );
-
- 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]$text>' );
-
- 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$text>]' );
-
- 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$text>' );
+ 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$text>' );
+
+ // 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$text>' );
+
+ 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[]oo$text>bar' );
+
+ 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$text>' );
+
+ 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$text>' );
+
+ 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$text>]' );
+
+ 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]$text>' );
+
+ 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$text>' );
+
+ 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$text>' );
+
+ 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]$text>' );
+
+ 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]$text>' );
+
+ 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$text>]' );
+
+ 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;
+ } );
+ } );
+ } );
+} );