From 8ad8b099f5da8fb65c58e5aa5825d84fc33ed8b0 Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Thu, 11 Jan 2018 14:02:44 +0100 Subject: [PATCH 01/18] Aligning the package to the new default theme. --- theme/icons/link.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/theme/icons/link.svg b/theme/icons/link.svg index ebb1d5b..d40a224 100644 --- a/theme/icons/link.svg +++ b/theme/icons/link.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file From 6a34fb1705a16b392177d338682129251dcba98a Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Fri, 12 Jan 2018 12:00:17 +0100 Subject: [PATCH 02/18] Used 1.5px thick icons for better contrast --- theme/icons/link.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/theme/icons/link.svg b/theme/icons/link.svg index d40a224..00a8378 100644 --- a/theme/icons/link.svg +++ b/theme/icons/link.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file From 8aaf1229fa31acbdb039f30d7c73cb7d26f43aa5 Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Mon, 15 Jan 2018 11:31:06 +0100 Subject: [PATCH 03/18] Added unlink icon. --- theme/icons/unlink.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/theme/icons/unlink.svg b/theme/icons/unlink.svg index 3d0d3bf..bf1eeba 100644 --- a/theme/icons/unlink.svg +++ b/theme/icons/unlink.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file From fd4863e237bf0f69662a5ea86027c71d7a6f738d Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Tue, 16 Jan 2018 13:40:00 +0100 Subject: [PATCH 04/18] Simplified the link editing form. --- src/link.js | 16 +++++++------- src/ui/linkformview.js | 47 +++++++++++------------------------------- theme/linkform.css | 21 +++++-------------- 3 files changed, 25 insertions(+), 59 deletions(-) diff --git a/src/link.js b/src/link.js index 1ec8714..1b19076 100644 --- a/src/link.js +++ b/src/link.js @@ -86,14 +86,14 @@ export default class Link extends Plugin { const editor = this.editor; const formView = new LinkFormView( editor.locale ); const linkCommand = editor.commands.get( 'link' ); - const unlinkCommand = editor.commands.get( 'unlink' ); + // const unlinkCommand = editor.commands.get( 'unlink' ); 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 ); - formView.unlinkButtonView.bind( 'isEnabled' ).to( unlinkCommand ); + // formView.unlinkButtonView.bind( 'isEnabled' ).to( unlinkCommand ); // Execute link command after clicking on formView `Save` button. this.listenTo( formView, 'submit', () => { @@ -102,10 +102,10 @@ export default class Link extends Plugin { } ); // Execute unlink command after clicking on formView `Unlink` button. - this.listenTo( formView, 'unlink', () => { - editor.execute( 'unlink' ); - this._hidePanel( true ); - } ); + // this.listenTo( formView, 'unlink', () => { + // editor.execute( 'unlink' ); + // this._hidePanel( true ); + // } ); // Hide the panel after clicking on formView `Cancel` button. this.listenTo( formView, 'cancel', () => this._hidePanel( true ) ); @@ -218,7 +218,7 @@ export default class Link extends Plugin { _showPanel( focusInput ) { const editor = this.editor; const linkCommand = editor.commands.get( 'link' ); - const unlinkCommand = editor.commands.get( 'unlink' ); + // const unlinkCommand = editor.commands.get( 'unlink' ); const editing = editor.editing; const showViewDocument = editing.view; const showIsCollapsed = showViewDocument.selection.isCollapsed; @@ -265,7 +265,7 @@ export default class Link extends Plugin { } // https://github.com/ckeditor/ckeditor5-link/issues/53 - this.formView.unlinkButtonView.isVisible = unlinkCommand.isEnabled; + // this.formView.unlinkButtonView.isVisible = unlinkCommand.isEnabled; // 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 diff --git a/src/ui/linkformview.js b/src/ui/linkformview.js index 3e186ae..29ec569 100644 --- a/src/ui/linkformview.js +++ b/src/ui/linkformview.js @@ -19,6 +19,8 @@ import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker'; import FocusCycler from '@ckeditor/ckeditor5-ui/src/focuscycler'; import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler'; +import checkIcon from '@ckeditor/ckeditor5-core/theme/icons/check.svg'; +import cancelIcon from '@ckeditor/ckeditor5-core/theme/icons/cancel.svg'; import '../../theme/linkform.css'; /** @@ -65,7 +67,7 @@ export default class LinkFormView extends View { * * @member {module:ui/button/buttonview~ButtonView} */ - this.saveButtonView = this._createButton( t( 'Save' ) ); + this.saveButtonView = this._createButton( t( 'Save' ), checkIcon ); this.saveButtonView.type = 'submit'; /** @@ -73,14 +75,7 @@ export default class LinkFormView extends View { * * @member {module:ui/button/buttonview~ButtonView} */ - this.cancelButtonView = this._createButton( t( 'Cancel' ), 'cancel' ); - - /** - * The Unlink button view. - * - * @member {module:ui/button/buttonview~ButtonView} - */ - this.unlinkButtonView = this._createButton( t( 'Unlink' ), 'unlink' ); + this.cancelButtonView = this._createButton( t( 'Cancel' ), cancelIcon, 'cancel' ); /** * A collection of views which can be focused in the form. @@ -133,21 +128,8 @@ export default class LinkFormView extends View { children: [ this.urlInputView, - { - tag: 'div', - - attributes: { - class: [ - 'ck-link-form__actions' - ] - }, - - children: [ - this.saveButtonView, - this.cancelButtonView, - this.unlinkButtonView - ] - } + this.cancelButtonView, + this.saveButtonView ] } ); } @@ -164,9 +146,8 @@ export default class LinkFormView extends View { const childViews = [ this.urlInputView, - this.saveButtonView, this.cancelButtonView, - this.unlinkButtonView + this.saveButtonView ]; childViews.forEach( v => { @@ -209,15 +190,17 @@ export default class LinkFormView extends View { * Creates a button view. * * @private - * @param {String} label The button label + * @param {String} label The button label. + * @param {String} icon The button's icon. * @param {String} [eventName] An event name that the `ButtonView#execute` event will be delegated to. * @returns {module:ui/button/buttonview~ButtonView} The button view instance. */ - _createButton( label, eventName ) { + _createButton( label, icon, eventName ) { const button = new ButtonView( this.locale ); button.label = label; - button.withText = true; + button.icon = icon; + button.tooltip = true; if ( eventName ) { button.delegate( 'execute' ).to( this, eventName ); @@ -239,9 +222,3 @@ export default class LinkFormView extends View { * * @event cancel */ - -/** - * Fired when the {@link #unlinkButtonView} is clicked. - * - * @event unlink - */ diff --git a/theme/linkform.css b/theme/linkform.css index 305cab9..66c26cc 100644 --- a/theme/linkform.css +++ b/theme/linkform.css @@ -3,19 +3,8 @@ * For licensing, see LICENSE.md. */ -.ck-link-form { - overflow: hidden; -} - -.ck-link-form__actions { - clear: both; - - & .ck-button { - float: right; - - /* The "Unlink" button.*/ - &:last-child { - float: left; - } - } -} +/* + * Note: This file should contain the wireframe styles only. But since there are no such styles, + * it acts as a message to the builder telling that it should look for the corresponding styles + * **in the theme** when compiling the editor. + */ From 345f0c2dbd263c77d6d9c09ce2be5f0c5b1c5399 Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Tue, 16 Jan 2018 16:22:46 +0100 Subject: [PATCH 05/18] Implemented LinkActionsView as an intermediate step before editing a link. Refactored the LinkFormView. --- src/link.js | 218 +++++++++++++++++++++++++++-------- src/ui/linkactionsview.js | 232 ++++++++++++++++++++++++++++++++++++++ src/ui/linkformview.js | 8 +- theme/linkactions.css | 11 ++ theme/linkform.css | 14 ++- 5 files changed, 426 insertions(+), 57 deletions(-) create mode 100644 src/ui/linkactionsview.js create mode 100644 theme/linkactions.css diff --git a/src/link.js b/src/link.js index 1b19076..fe75b3a 100644 --- a/src/link.js +++ b/src/link.js @@ -18,6 +18,7 @@ import clickOutsideHandler from '@ckeditor/ckeditor5-ui/src/bindings/clickoutsid 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'; @@ -54,12 +55,19 @@ export default class Link extends Plugin { 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._createForm(); + this.formView = this._createFormView(); /** * The contextual balloon plugin instance. @@ -73,7 +81,40 @@ export default class Link extends Plugin { this._createToolbarLinkButton(); // Attach lifecycle actions to the the balloon. - this._attachActions(); + 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' ); + + actionsView.preView.bind( 'href' ).to( linkCommand, 'value' ); + + // Execute unlink command after clicking on the "Unlink" button. + this.listenTo( actionsView, 'edit', () => { + this._showForm( true ); + } ); + + // Execute unlink command after clicking on the "Unlink" button. + this.listenTo( actionsView, 'unlink', () => { + editor.execute( 'unlink' ); + this._hidePanel( true ); + } ); + + // Close the panel on esc key press when the form has focus. + actionsView.keystrokes.set( 'Esc', ( data, cancel ) => { + this._hidePanel( true ); + cancel(); + } ); + + return actionsView; } /** @@ -82,18 +123,16 @@ export default class Link extends Plugin { * @private * @returns {module:link/ui/linkformview~LinkFormView} The link form instance. */ - _createForm() { + _createFormView() { const editor = this.editor; const formView = new LinkFormView( editor.locale ); const linkCommand = editor.commands.get( 'link' ); - // const unlinkCommand = editor.commands.get( 'unlink' ); 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 ); - // formView.unlinkButtonView.bind( 'isEnabled' ).to( unlinkCommand ); // Execute link command after clicking on formView `Save` button. this.listenTo( formView, 'submit', () => { @@ -101,14 +140,10 @@ export default class Link extends Plugin { this._hidePanel( true ); } ); - // Execute unlink command after clicking on formView `Unlink` button. - // this.listenTo( formView, 'unlink', () => { - // editor.execute( 'unlink' ); - // this._hidePanel( true ); - // } ); - // Hide the panel after clicking on formView `Cancel` button. - this.listenTo( formView, 'cancel', () => this._hidePanel( true ) ); + this.listenTo( formView, 'cancel', () => { + this._hidePanel( true ); + } ); // Close the panel on esc key press when the form has focus. formView.keystrokes.set( 'Esc', ( data, cancel ) => { @@ -136,7 +171,7 @@ export default class Link extends Plugin { cancel(); if ( linkCommand.isEnabled ) { - this._showPanel( true ); + this._showForm( true ); } } ); @@ -153,7 +188,9 @@ export default class Link extends Plugin { button.bind( 'isEnabled' ).to( linkCommand, 'isEnabled' ); // Show the panel on button click. - this.listenTo( button, 'execute', () => this._showPanel( true ) ); + this.listenTo( button, 'execute', () => { + this._showForm( true ); + } ); return button; } ); @@ -165,7 +202,7 @@ export default class Link extends Plugin { * * @private */ - _attachActions() { + _enableUserBalloonInteractions() { const viewDocument = this.editor.editing.view; // Handle click on view document and show panel when selection is placed inside the link element. @@ -175,14 +212,14 @@ export default class Link extends Plugin { if ( parentLink ) { // Then show panel but keep focus inside editor editable. - this._showPanel(); + this._showActions(); } } ); // Focus the form if the balloon is visible and the Tab key has been pressed. this.editor.keystrokes.set( 'Tab', ( data, cancel ) => { - if ( this._balloon.visibleView === this.formView && !this.formView.focusTracker.isFocused ) { - this.formView.focus(); + if ( this._areActionsVisible && !this.actionsView.focusTracker.isFocused ) { + this.actionsView.focus(); cancel(); } }, { @@ -194,7 +231,7 @@ export default class Link extends Plugin { // Close the panel on the Esc key press when the editable has focus and the balloon is visible. this.editor.keystrokes.set( 'Esc', ( data, cancel ) => { - if ( this._balloon.visibleView === this.formView ) { + if ( this._isAnyUIVisible ) { this._hidePanel(); cancel(); } @@ -203,22 +240,77 @@ export default class Link extends Plugin { // Close on click outside of balloon panel element. clickOutsideHandler( { emitter: this.formView, - activator: () => this._balloon.hasView( this.formView ), + activator: () => this._isAnyUIVisible, contextElements: [ this._balloon.view.element ], callback: () => this._hidePanel() } ); } + /** + * Adds the {@link #actionsView} to the {@link #_balloon}. + * + * @protected + */ + _showActions() { + this._startRepositioningUponRender(); + + if ( !this._areActionsInPanel ) { + this._balloon.add( { + view: this.actionsView, + position: this._getBalloonPositionData() + } ); + } + } + /** * Adds the {@link #formView} to the {@link #_balloon}. * * @protected * @param {Boolean} [focusInput=false] When `true`, the link form will be focused on panel show. */ - _showPanel( focusInput ) { + _showForm( focusInput ) { const editor = this.editor; const linkCommand = editor.commands.get( 'link' ); - // const unlinkCommand = editor.commands.get( 'unlink' ); + + if ( !this._areActionsInPanel ) { + this._startRepositioningUponRender(); + } + + if ( this._isFormInPanel ) { + // Check if formView should be focused and focus it if is visible. + if ( focusInput && this._balloon.visibleView === this.formView ) { + this.formView.urlInputView.select(); + } + } else { + this._balloon.add( { + view: this.formView, + position: this._getBalloonPositionData() + } ); + + if ( focusInput ) { + 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 || ''; + } + + /** + * 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 _hidePanel} to learn when the UI stops reacting to the `render` event. + * + * @protected + */ + _startRepositioningUponRender() { + const editor = this.editor; const editing = editor.editing; const showViewDocument = editing.view; const showIsCollapsed = showViewDocument.selection.isCollapsed; @@ -247,39 +339,60 @@ export default class Link extends Plugin { this._balloon.updatePosition( this._getBalloonPositionData() ); } } ); + } - if ( this._balloon.hasView( this.formView ) ) { - // Check if formView should be focused and focus it if is visible. - if ( focusInput && this._balloon.visibleView === this.formView ) { - this.formView.urlInputView.select(); - } - } else { - this._balloon.add( { - view: this.formView, - position: this._getBalloonPositionData() - } ); + /** + * Returns true when {@link #formView} is in the {@link #_balloon}. + * + * @readonly + * @protected + * @type {Boolean} + */ + get _isFormInPanel() { + return this._balloon.hasView( this.formView ); + } - if ( focusInput ) { - this.formView.urlInputView.select(); - } - } + /** + * Returns true when {@link #actionsView} is in the {@link #_balloon}. + * + * @readonly + * @protected + * @type {Boolean} + */ + get _areActionsInPanel() { + return this._balloon.hasView( this.actionsView ); + } - // https://github.com/ckeditor/ckeditor5-link/issues/53 - // this.formView.unlinkButtonView.isVisible = unlinkCommand.isEnabled; + /** + * 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; + } - // 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 || ''; + /** + * Returns true when {@link #actionsView} or {@link #formView} is in the {@link #_balloon} and it is + * currently visible. + * + * @readonly + * @protected + * @type {Boolean} + */ + get _isAnyUIVisible() { + const visibleView = this._balloon.visibleView; + + return visibleView == this.formView || this._areActionsVisible; } /** * Removes the {@link #formView} from the {@link #_balloon}. * - * See {@link #_showPanel}. + * See {@link #_showForm}, {@link #_showActions}. * * @protected * @param {Boolean} [focusEditable=false] When `true`, editable focus will be restored on panel hide. @@ -287,7 +400,7 @@ export default class Link extends Plugin { _hidePanel( focusEditable ) { this.stopListening( this.editor.editing.view, 'render' ); - if ( !this._balloon.hasView( this.formView ) ) { + if ( !this._isFormInPanel && !this._areActionsInPanel ) { return; } @@ -295,8 +408,15 @@ export default class Link extends Plugin { this.editor.editing.view.focus(); } - this.stopListening( this.editor.editing.view, 'render' ); - this._balloon.remove( this.formView ); + const balloon = this._balloon; + + if ( this._isFormInPanel ) { + balloon.remove( this.formView ); + } + + if ( this._areActionsInPanel ) { + balloon.remove( this.actionsView ); + } } /** diff --git a/src/ui/linkactionsview.js b/src/ui/linkactionsview.js new file mode 100644 index 0000000..6249254 --- /dev/null +++ b/src/ui/linkactionsview.js @@ -0,0 +1,232 @@ +/** + * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +/** + * @module link/ui/linkactionsview + */ + +import View from '@ckeditor/ckeditor5-ui/src/view'; +import ViewCollection from '@ckeditor/ckeditor5-ui/src/viewcollection'; + +import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview'; + +import submitHandler from '@ckeditor/ckeditor5-ui/src/bindings/submithandler'; +import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker'; +import FocusCycler from '@ckeditor/ckeditor5-ui/src/focuscycler'; +import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler'; + +import unlinkIcon from '../../theme/icons/unlink.svg'; +import linkIcon from '../../theme/icons/link.svg'; +import '../../theme/linkactions.css'; + +/** + * The link actions view class. This view displays link preview, allows + * unlinking or editing the link. + * + * @extends module:ui/view~View + */ +export default class LinkActionsView extends View { + /** + * @inheritDoc + */ + constructor( locale ) { + super( locale ); + + const t = locale.t; + + /** + * Tracks information about DOM focus in the actions. + * + * @readonly + * @member {module:utils/focustracker~FocusTracker} + */ + this.focusTracker = new FocusTracker(); + + /** + * An instance of the {@link module:utils/keystrokehandler~KeystrokeHandler}. + * + * @readonly + * @member {module:utils/keystrokehandler~KeystrokeHandler} + */ + this.keystrokes = new KeystrokeHandler(); + + /** + * The href preview view. + * + * @member {module:ui/view~View} + */ + this.preView = this._createPreView(); + + /** + * The unlink button view. + * + * @member {module:ui/button/buttonview~ButtonView} + */ + this.unlinkButtonView = this._createButton( t( 'Unlink' ), unlinkIcon, 'unlink' ); + + /** + * The edit link button view. + * + * @member {module:ui/button/buttonview~ButtonView} + */ + this.editButtonView = this._createButton( t( 'Edit' ), linkIcon, 'edit' ); + + /** + * A collection of views which can be focused in the view. + * + * @readonly + * @protected + * @member {module:ui/viewcollection~ViewCollection} + */ + this._focusables = new ViewCollection(); + + /** + * Helps cycling over {@link #_focusables} in the view. + * + * @readonly + * @protected + * @member {module:ui/focuscycler~FocusCycler} + */ + this._focusCycler = new FocusCycler( { + focusables: this._focusables, + focusTracker: this.focusTracker, + keystrokeHandler: this.keystrokes, + actions: { + // Navigate fields backwards using the Shift + Tab keystroke. + focusPrevious: 'shift + tab', + + // Navigate fields forwards using the Tab key. + focusNext: 'tab' + } + } ); + + this.setTemplate( { + tag: 'div', + + attributes: { + class: [ + 'ck-link-actions', + ], + + // https://github.com/ckeditor/ckeditor5-link/issues/90 + tabindex: '-1' + }, + + children: [ + this.preView, + this.editButtonView, + this.unlinkButtonView + ] + } ); + } + + /** + * @inheritDoc + */ + render() { + super.render(); + + submitHandler( { + view: this + } ); + + const childViews = [ + this.preView, + this.editButtonView, + this.unlinkButtonView + ]; + + childViews.forEach( v => { + // Register the view as focusable. + this._focusables.add( v ); + + // Register the view in the focus tracker. + this.focusTracker.add( v.element ); + } ); + + // Start listening for the keystrokes coming from #element. + this.keystrokes.listenTo( this.element ); + } + + /** + * Focuses the fist {@link #_focusables} in the actions. + */ + focus() { + this._focusCycler.focusFirst(); + } + + /** + * Creates a button view. + * + * @private + * @param {String} label The button label. + * @param {String} icon The button's icon. + * @param {String} [eventName] An event name that the `ButtonView#execute` event will be delegated to. + * @returns {module:ui/button/buttonview~ButtonView} The button view instance. + */ + _createButton( label, icon, eventName ) { + const button = new ButtonView( this.locale ); + + button.set( { + label, + icon, + tooltip: true + } ); + + if ( eventName ) { + button.delegate( 'execute' ).to( this, eventName ); + } + + return button; + } + + /** + * Creates a link href preview view. + * + * @private + * @returns {module:ui/view~View} The href preview view instance. + */ + _createPreView() { + const preView = new View( this.locale ); + const bind = preView.bindTemplate; + + preView.set( 'href' ); + + preView.setTemplate( { + tag: 'a', + attributes: { + class: [ + 'ck-link-actions__preview' + ], + target: '_blank', + href: bind.to( 'href' ), + tabindex: -1 + }, + children: [ + { + text: bind.to( 'href' ) + } + ] + } ); + + preView.focus = function() { + this.element.focus(); + }; + + return preView; + } +} + +/** + * Fired when the {@link #editButtonView} is clicked. + * + * @event edit + */ + +/** + * Fired when the {@link #unlinkButtonView} is clicked. + * + * @event unlink + */ diff --git a/src/ui/linkformview.js b/src/ui/linkformview.js index 29ec569..7eec2e1 100644 --- a/src/ui/linkformview.js +++ b/src/ui/linkformview.js @@ -198,9 +198,11 @@ export default class LinkFormView extends View { _createButton( label, icon, eventName ) { const button = new ButtonView( this.locale ); - button.label = label; - button.icon = icon; - button.tooltip = true; + button.set( { + label, + icon, + tooltip: true + } ); if ( eventName ) { button.delegate( 'execute' ).to( this, eventName ); diff --git a/theme/linkactions.css b/theme/linkactions.css new file mode 100644 index 0000000..e644271 --- /dev/null +++ b/theme/linkactions.css @@ -0,0 +1,11 @@ +/* + * Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +.ck-link-actions { + & .ck-link-actions__preview { + display: inline-block; + overflow: hidden; + } +} diff --git a/theme/linkform.css b/theme/linkform.css index 66c26cc..fc4ff86 100644 --- a/theme/linkform.css +++ b/theme/linkform.css @@ -3,8 +3,12 @@ * For licensing, see LICENSE.md. */ -/* - * Note: This file should contain the wireframe styles only. But since there are no such styles, - * it acts as a message to the builder telling that it should look for the corresponding styles - * **in the theme** when compiling the editor. - */ +.ck-link-form { + & .ck-labeled-input { + display: inline-block; + } + + & .ck-label { + display: none; + } +} From a08804e32ec1f821d0ff48da051d1b8315c9c5d8 Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Tue, 16 Jan 2018 17:46:06 +0100 Subject: [PATCH 06/18] Used a different icon and changed button order in the link form view. --- src/ui/linkactionsview.js | 4 ++-- src/ui/linkformview.js | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/ui/linkactionsview.js b/src/ui/linkactionsview.js index 6249254..3ab3971 100644 --- a/src/ui/linkactionsview.js +++ b/src/ui/linkactionsview.js @@ -18,7 +18,7 @@ import FocusCycler from '@ckeditor/ckeditor5-ui/src/focuscycler'; import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler'; import unlinkIcon from '../../theme/icons/unlink.svg'; -import linkIcon from '../../theme/icons/link.svg'; +import pencilIcon from '@ckeditor/ckeditor5-core/theme/icons/pencil.svg'; import '../../theme/linkactions.css'; /** @@ -71,7 +71,7 @@ export default class LinkActionsView extends View { * * @member {module:ui/button/buttonview~ButtonView} */ - this.editButtonView = this._createButton( t( 'Edit' ), linkIcon, 'edit' ); + this.editButtonView = this._createButton( t( 'Edit link' ), pencilIcon, 'edit' ); /** * A collection of views which can be focused in the view. diff --git a/src/ui/linkformview.js b/src/ui/linkformview.js index 7eec2e1..6f671ec 100644 --- a/src/ui/linkformview.js +++ b/src/ui/linkformview.js @@ -128,8 +128,8 @@ export default class LinkFormView extends View { children: [ this.urlInputView, - this.cancelButtonView, - this.saveButtonView + this.saveButtonView, + this.cancelButtonView ] } ); } @@ -146,8 +146,8 @@ export default class LinkFormView extends View { const childViews = [ this.urlInputView, - this.cancelButtonView, - this.saveButtonView + this.saveButtonView, + this.cancelButtonView ]; childViews.forEach( v => { From 7e9a10ff15c6f2b5a7cb39669a524d3169542875 Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Wed, 17 Jan 2018 12:12:08 +0100 Subject: [PATCH 07/18] Enabled tooltip for link href preview. --- src/ui/linkactionsview.js | 19 +++++++++++++++++-- theme/linkactions.css | 6 +++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/ui/linkactionsview.js b/src/ui/linkactionsview.js index 3ab3971..0fbaa96 100644 --- a/src/ui/linkactionsview.js +++ b/src/ui/linkactionsview.js @@ -11,6 +11,7 @@ import View from '@ckeditor/ckeditor5-ui/src/view'; import ViewCollection from '@ckeditor/ckeditor5-ui/src/viewcollection'; import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview'; +import TooltipView from '@ckeditor/ckeditor5-ui/src/tooltip/tooltipview'; import submitHandler from '@ckeditor/ckeditor5-ui/src/bindings/submithandler'; import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker'; @@ -190,8 +191,11 @@ export default class LinkActionsView extends View { */ _createPreView() { const preView = new View( this.locale ); + const tooltipView = new TooltipView(); const bind = preView.bindTemplate; + const t = this.t; + tooltipView.set( 'text', t( 'Open link in new tab' ) ); preView.set( 'href' ); preView.setTemplate( { @@ -206,8 +210,19 @@ export default class LinkActionsView extends View { }, children: [ { - text: bind.to( 'href' ) - } + tag: 'span', + attributes: { + class: [ + 'ck-link-actions__preview__text' + ] + }, + children: [ + { + text: bind.to( 'href' ), + } + ] + }, + tooltipView ] } ); diff --git a/theme/linkactions.css b/theme/linkactions.css index e644271..a917423 100644 --- a/theme/linkactions.css +++ b/theme/linkactions.css @@ -6,6 +6,10 @@ .ck-link-actions { & .ck-link-actions__preview { display: inline-block; - overflow: hidden; + + & .ck-link-actions__preview__text { + display: inline-block; + overflow: hidden; + } } } From a12b9438d971b068a27ec7019097d224068cc6a9 Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Wed, 17 Jan 2018 12:57:42 +0100 Subject: [PATCH 08/18] Made the link actions preview an instance of ButtonView. --- src/link.js | 2 +- src/ui/linkactionsview.js | 69 ++++++++++++++++++--------------------- theme/linkactions.css | 3 +- 3 files changed, 34 insertions(+), 40 deletions(-) diff --git a/src/link.js b/src/link.js index fe75b3a..9ae24b3 100644 --- a/src/link.js +++ b/src/link.js @@ -95,7 +95,7 @@ export default class Link extends Plugin { const actionsView = new LinkActionsView( editor.locale ); const linkCommand = editor.commands.get( 'link' ); - actionsView.preView.bind( 'href' ).to( linkCommand, 'value' ); + actionsView.bind( 'href' ).to( linkCommand, 'value' ); // Execute unlink command after clicking on the "Unlink" button. this.listenTo( actionsView, 'edit', () => { diff --git a/src/ui/linkactionsview.js b/src/ui/linkactionsview.js index 0fbaa96..a83076f 100644 --- a/src/ui/linkactionsview.js +++ b/src/ui/linkactionsview.js @@ -11,7 +11,6 @@ import View from '@ckeditor/ckeditor5-ui/src/view'; import ViewCollection from '@ckeditor/ckeditor5-ui/src/viewcollection'; import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview'; -import TooltipView from '@ckeditor/ckeditor5-ui/src/tooltip/tooltipview'; import submitHandler from '@ckeditor/ckeditor5-ui/src/bindings/submithandler'; import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker'; @@ -58,7 +57,7 @@ export default class LinkActionsView extends View { * * @member {module:ui/view~View} */ - this.preView = this._createPreView(); + this.previewButton = this._createPreviewButton(); /** * The unlink button view. @@ -74,6 +73,14 @@ export default class LinkActionsView extends View { */ this.editButtonView = this._createButton( t( 'Edit link' ), pencilIcon, 'edit' ); + /** + * Value of the "href" attribute of the link to use in the {@link #previewButton}. + * + * @observable + * @member {String} + */ + this.set( 'href' ); + /** * A collection of views which can be focused in the view. * @@ -116,7 +123,7 @@ export default class LinkActionsView extends View { }, children: [ - this.preView, + this.previewButton, this.editButtonView, this.unlinkButtonView ] @@ -134,7 +141,7 @@ export default class LinkActionsView extends View { } ); const childViews = [ - this.preView, + this.previewButton, this.editButtonView, this.unlinkButtonView ]; @@ -184,53 +191,41 @@ export default class LinkActionsView extends View { } /** - * Creates a link href preview view. + * Creates a link href preview button. * * @private - * @returns {module:ui/view~View} The href preview view instance. + * @returns {module:ui/button/buttonview~ButtonView} The button view instance. */ - _createPreView() { - const preView = new View( this.locale ); - const tooltipView = new TooltipView(); - const bind = preView.bindTemplate; + _createPreviewButton() { + const button = new ButtonView( this.locale ); + const bind = this.bindTemplate; const t = this.t; - tooltipView.set( 'text', t( 'Open link in new tab' ) ); - preView.set( 'href' ); + button.set( { + withText: true, + tooltip: t( 'Open link in new tab' ) + } ); - preView.setTemplate( { - tag: 'a', + button.extendTemplate( { attributes: { class: [ 'ck-link-actions__preview' ], - target: '_blank', href: bind.to( 'href' ), - tabindex: -1 - }, - children: [ - { - tag: 'span', - attributes: { - class: [ - 'ck-link-actions__preview__text' - ] - }, - children: [ - { - text: bind.to( 'href' ), - } - ] - }, - tooltipView - ] + target: '_blank' + } } ); - preView.focus = function() { - this.element.focus(); - }; + button.bind( 'label' ).to( this, 'href', href => { + return href || t( 'This link has no URL' ); + } ); + + button.bind( 'isEnabled' ).to( this, 'href', href => !!href ); - return preView; + button.template.tag = 'a'; + button.template.eventListeners = {}; + + return button; } } diff --git a/theme/linkactions.css b/theme/linkactions.css index a917423..e3657c3 100644 --- a/theme/linkactions.css +++ b/theme/linkactions.css @@ -7,8 +7,7 @@ & .ck-link-actions__preview { display: inline-block; - & .ck-link-actions__preview__text { - display: inline-block; + & .ck-button__label { overflow: hidden; } } From 65b90900fe03e0919230e94fbd76a8bf225b40e4 Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Thu, 18 Jan 2018 14:53:24 +0100 Subject: [PATCH 09/18] Made the keystroke and the toolbar button always open the link actions UI first, if there's a link under the selection. --- src/link.js | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/src/link.js b/src/link.js index 9ae24b3..b628342 100644 --- a/src/link.js +++ b/src/link.js @@ -170,9 +170,7 @@ export default class Link extends Plugin { // Prevent focusing the search bar in FF and opening new tab in Edge. #153, #154. cancel(); - if ( linkCommand.isEnabled ) { - this._showForm( true ); - } + this._showUI(); } ); editor.ui.componentFactory.add( 'link', locale => { @@ -188,9 +186,7 @@ export default class Link extends Plugin { button.bind( 'isEnabled' ).to( linkCommand, 'isEnabled' ); // Show the panel on button click. - this.listenTo( button, 'execute', () => { - this._showForm( true ); - } ); + this.listenTo( button, 'execute', () => this._showUI() ); return button; } ); @@ -301,6 +297,27 @@ export default class Link extends Plugin { this.formView.urlInputView.inputView.element.value = linkCommand.value || ''; } + /** + * 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 ) { + return; + } + + if ( linkCommand.value ) { + this._showActions(); + } else { + this._showForm( true ); + } + } + /** * 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. From 99d296f82ef5d2b002b7071cecae87ee530c87c0 Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Wed, 24 Jan 2018 11:31:12 +0100 Subject: [PATCH 10/18] Updated link and unlink icons. --- theme/icons/link.svg | 2 +- theme/icons/unlink.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/theme/icons/link.svg b/theme/icons/link.svg index 00a8378..0eda832 100644 --- a/theme/icons/link.svg +++ b/theme/icons/link.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/theme/icons/unlink.svg b/theme/icons/unlink.svg index bf1eeba..c93159e 100644 --- a/theme/icons/unlink.svg +++ b/theme/icons/unlink.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file From 357e321c43b2734cfc6f7bbbbcda0f42e2d6189e Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Wed, 31 Jan 2018 10:38:48 +0100 Subject: [PATCH 11/18] Refactored link plugin along with tests to support a two-step link UI. --- src/link.js | 219 +++++++++++---------- tests/link.js | 514 +++++++++++++++++++++++++++----------------------- 2 files changed, 396 insertions(+), 337 deletions(-) diff --git a/src/link.js b/src/link.js index c4c09fd..0e38710 100644 --- a/src/link.js +++ b/src/link.js @@ -94,23 +94,26 @@ export default class Link extends Plugin { 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 "Unlink" button. + // Execute unlink command after clicking on the "Edit" button. this.listenTo( actionsView, 'edit', () => { - this._showForm( true ); + this._addFormView(); } ); // Execute unlink command after clicking on the "Unlink" button. this.listenTo( actionsView, 'unlink', () => { editor.execute( 'unlink' ); - this._hidePanel( true ); + this._hideUI(); } ); - // Close the panel on esc key press when the form has focus. + // Close the panel on esc key press when the **actions have focus**. actionsView.keystrokes.set( 'Esc', ( data, cancel ) => { - this._hidePanel( true ); + this._hideUI(); cancel(); } ); @@ -134,20 +137,20 @@ export default class Link extends Plugin { formView.urlInputView.bind( 'isReadOnly' ).to( linkCommand, 'isEnabled', value => !value ); formView.saveButtonView.bind( 'isEnabled' ).to( linkCommand ); - // Execute link command after clicking on formView `Save` button. + // Execute link command after clicking the "Save" button. this.listenTo( formView, 'submit', () => { editor.execute( 'link', formView.urlInputView.inputView.element.value ); - this._hidePanel( true ); + this._removeFormView(); } ); - // Hide the panel after clicking on formView `Cancel` button. + // Hide the panel after clicking the "Cancel" button. this.listenTo( formView, 'cancel', () => { - this._hidePanel( true ); + this._removeFormView(); } ); - // Close the panel on esc key press when the form has focus. + // Close the panel on esc key press when the **form has focus**. formView.keystrokes.set( 'Esc', ( data, cancel ) => { - this._hidePanel( true ); + this._removeFormView(); cancel(); } ); @@ -170,7 +173,9 @@ export default class Link extends Plugin { // Prevent focusing the search bar in FF and opening new tab in Edge. #153, #154. cancel(); - this._showUI(); + if ( linkCommand.isEnabled ) { + this._showUI(); + } } ); editor.ui.componentFactory.add( 'link', locale => { @@ -208,7 +213,7 @@ export default class Link extends Plugin { if ( parentLink ) { // Then show panel but keep focus inside editor editable. - this._showActions(); + this._showUI(); } } ); @@ -227,8 +232,8 @@ export default class Link extends Plugin { // 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._isAnyUIVisible ) { - this._hidePanel(); + if ( this._isUIVisible ) { + this._hideUI(); cancel(); } } ); @@ -236,9 +241,9 @@ export default class Link extends Plugin { // Close on click outside of balloon panel element. clickOutsideHandler( { emitter: this.formView, - activator: () => this._isAnyUIVisible, + activator: () => this._isUIVisible, contextElements: [ this._balloon.view.element ], - callback: () => this._hidePanel() + callback: () => this._hideUI() } ); } @@ -247,46 +252,28 @@ export default class Link extends Plugin { * * @protected */ - _showActions() { - this._startRepositioningUponRender(); - - if ( !this._areActionsInPanel ) { - this._balloon.add( { - view: this.actionsView, - position: this._getBalloonPositionData() - } ); - } + _addActionsView() { + this._balloon.add( { + view: this.actionsView, + position: this._getBalloonPositionData() + } ); } /** * Adds the {@link #formView} to the {@link #_balloon}. * * @protected - * @param {Boolean} [focusInput=false] When `true`, the link form will be focused on panel show. */ - _showForm( focusInput ) { + _addFormView() { const editor = this.editor; const linkCommand = editor.commands.get( 'link' ); - if ( !this._areActionsInPanel ) { - this._startRepositioningUponRender(); - } - - if ( this._isFormInPanel ) { - // Check if formView should be focused and focus it if is visible. - if ( focusInput && this._balloon.visibleView === this.formView ) { - this.formView.urlInputView.select(); - } - } else { - this._balloon.add( { - view: this.formView, - position: this._getBalloonPositionData() - } ); + this._balloon.add( { + view: this.formView, + position: this._getBalloonPositionData() + } ); - if ( focusInput ) { - this.formView.urlInputView.select(); - } - } + 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 @@ -297,6 +284,21 @@ export default class Link extends Plugin { 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}. @@ -307,55 +309,99 @@ export default class Link extends Plugin { const editor = this.editor; const linkCommand = editor.commands.get( 'link' ); - if ( !linkCommand.isEnabled ) { + if ( !linkCommand.isEnabled || this._isUIInPanel ) { return; } - if ( linkCommand.value ) { - this._showActions(); - } else { - this._showForm( true ); + // 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 _hidePanel} to learn when the UI stops reacting to the `render` event. + * See: {@link _hideUI} to learn when the UI stops reacting to the `render` event. * * @protected */ - _startRepositioningUponRender() { + _startUpdatingUIOnViewRender() { const editor = this.editor; const editing = editor.editing; - const showViewDocument = editing.view; - const showIsCollapsed = showViewDocument.selection.isCollapsed; - const showSelectedLink = this._getSelectedLinkElement(); + const editingView = editing.view; + + let prevSelectedLink = this._getSelectedLinkElement(); + let prevSelectionParent = getSelectionParent(); - this.listenTo( showViewDocument, 'render', () => { - const renderSelectedLink = this._getSelectedLinkElement(); - const renderIsCollapsed = showViewDocument.selection.isCollapsed; - const hasSellectionExpanded = showIsCollapsed && !renderIsCollapsed; + this.listenTo( editingView, 'render', () => { + const selectedLink = this._getSelectedLinkElement(); + const selectionParent = getSelectionParent(); // Hide the panel if: - // * the selection went out of the original link element - // (e.g. paragraph containing the link was removed), - // * the selection has expanded - // upon the #render event. - if ( hasSellectionExpanded || showSelectedLink !== renderSelectedLink ) { - this._hidePanel( true ); + // + // * 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. + 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, upon #render, the balloon must be moved + // 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' ) ); + } } /** @@ -393,47 +439,28 @@ export default class Link extends Plugin { } /** - * Returns true when {@link #actionsView} or {@link #formView} is in the {@link #_balloon} and it is - * currently visible. + * Returns true when {@link #actionsView} or {@link #formView} is in the {@link #_balloon}. * * @readonly * @protected * @type {Boolean} */ - get _isAnyUIVisible() { - const visibleView = this._balloon.visibleView; - - return visibleView == this.formView || this._areActionsVisible; + get _isUIInPanel() { + return this._isFormInPanel || this._areActionsInPanel; } /** - * Removes the {@link #formView} from the {@link #_balloon}. - * - * See {@link #_showForm}, {@link #_showActions}. + * Returns true when {@link #actionsView} or {@link #formView} is in the {@link #_balloon} and it is + * currently visible. * + * @readonly * @protected - * @param {Boolean} [focusEditable=false] When `true`, editable focus will be restored on panel hide. + * @type {Boolean} */ - _hidePanel( focusEditable ) { - this.stopListening( this.editor.editing.view, 'render' ); - - if ( !this._isFormInPanel && !this._areActionsInPanel ) { - return; - } - - if ( focusEditable ) { - this.editor.editing.view.focus(); - } - - const balloon = this._balloon; - - if ( this._isFormInPanel ) { - balloon.remove( this.formView ); - } + get _isUIVisible() { + const visibleView = this._balloon.visibleView; - if ( this._areActionsInPanel ) { - balloon.remove( this.actionsView ); - } + return visibleView == this.formView || this._areActionsVisible; } /** diff --git a/tests/link.js b/tests/link.js index 4332962..c9f2982 100644 --- a/tests/link.js +++ b/tests/link.js @@ -13,16 +13,18 @@ import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-util 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 ViewRange from '@ckeditor/ckeditor5-engine/src/view/range'; import ClickObserver from '@ckeditor/ckeditor5-engine/src/view/observer/clickobserver'; testUtils.createSinonSandbox(); describe( 'Link', () => { - let editor, linkFeature, linkButton, balloon, formView, editorElement; + let editor, linkFeature, linkButton, balloon, formView, actionsView, editorElement; beforeEach( () => { editorElement = document.createElement( 'div' ); @@ -39,6 +41,7 @@ describe( '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( {} ); @@ -55,22 +58,55 @@ describe( 'Link', () => { } ); it( 'should be loaded', () => { - expect( linkFeature ).to.instanceOf( Link ); + expect( linkFeature ).to.be.instanceOf( Link ); } ); it( 'should load LinkEngine', () => { - expect( editor.plugins.get( LinkEngine ) ).to.instanceOf( LinkEngine ); + expect( editor.plugins.get( LinkEngine ) ).to.be.instanceOf( LinkEngine ); } ); it( 'should load ContextualBalloon', () => { - expect( editor.plugins.get( ContextualBalloon ) ).to.instanceOf( ContextualBalloon ); + expect( editor.plugins.get( ContextualBalloon ) ).to.be.instanceOf( ContextualBalloon ); } ); - it( 'should register click observer', () => { - expect( editor.editing.view.getObserver( ClickObserver ) ).to.instanceOf( ClickObserver ); + 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 ); + } ); + } ); } ); - describe( '_showPanel()', () => { + describe( '_showUI()', () => { let balloonAddSpy; beforeEach( () => { @@ -78,11 +114,30 @@ describe( 'Link', () => { editor.editing.view.isFocused = true; } ); - it( 'should add #formView to the #_balloon and attach the #_balloon to the selection when text fragment is selected', () => { + 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._showPanel(); + linkFeature._showUI(); expect( balloon.visibleView ).to.equal( formView ); sinon.assert.calledWithExactly( balloonAddSpy, { @@ -93,11 +148,11 @@ describe( 'Link', () => { } ); } ); - it( 'should add #formView to the #_balloon and attach the #_balloon to the selection when selection is collapsed', () => { + 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._showPanel(); + linkFeature._showUI(); expect( balloon.visibleView ).to.equal( formView ); sinon.assert.calledWithExactly( balloonAddSpy, { @@ -108,106 +163,47 @@ describe( 'Link', () => { } ); } ); - it( 'should add #formView to the #_balloon and attach the #_balloon to the link element when collapsed selection is inside ' + + 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._showPanel(); + linkFeature._showUI(); - expect( balloon.visibleView ).to.equal( formView ); + expect( balloon.visibleView ).to.equal( actionsView ); sinon.assert.calledWithExactly( balloonAddSpy, { - view: formView, + view: actionsView, position: { target: linkElement } } ); } ); - it( 'should not focus the #formView at default', () => { - const spy = testUtils.sinon.spy( formView.urlInputView, 'select' ); - - linkFeature._showPanel(); - sinon.assert.notCalled( spy ); - } ); - - it( 'should not focus the #formView when called with a `false` parameter', () => { - const spy = testUtils.sinon.spy( formView.urlInputView, 'select' ); - - linkFeature._showPanel( false ); - sinon.assert.notCalled( spy ); - } ); - - it( 'should not focus the #formView when called with a `true` parameter while the balloon is opened but link ' + - 'form is not visible', () => { - const spy = testUtils.sinon.spy( formView.urlInputView, 'select' ); - const viewMock = { - ready: true, - render: () => {}, - destroy: () => {} - }; - - linkFeature._showPanel( false ); - balloon.add( { view: viewMock } ); - linkFeature._showPanel( true ); - - sinon.assert.notCalled( spy ); - } ); - - it( 'should focus the #formView when called with a `true` parameter', () => { - const spy = testUtils.sinon.spy( formView.urlInputView, 'select' ); - - linkFeature._showPanel( true ); - sinon.assert.calledOnce( spy ); - } ); - - it( 'should focus the #formView when called with a `true` parameter while the balloon is open and the #formView is visible', () => { - const spy = testUtils.sinon.spy( formView.urlInputView, 'select' ); - - linkFeature._showPanel( false ); - linkFeature._showPanel( true ); - sinon.assert.calledOnce( spy ); - } ); - - it( 'should disable #formView elements when link and unlink commands are disabled', () => { + it( 'should disable #formView and #actionsView elements when link and unlink commands are disabled', () => { setModelData( editor.model, 'f[o]o' ); - linkFeature._showPanel(); + linkFeature._showUI(); editor.commands.get( 'link' ).isEnabled = true; editor.commands.get( 'unlink' ).isEnabled = true; - expect( formView.urlInputView.isReadOnly ).to.false; - expect( formView.saveButtonView.isEnabled ).to.true; - expect( formView.unlinkButtonView.isEnabled ).to.true; - expect( formView.cancelButtonView.isEnabled ).to.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.true; - expect( formView.saveButtonView.isEnabled ).to.false; - expect( formView.unlinkButtonView.isEnabled ).to.false; - expect( formView.cancelButtonView.isEnabled ).to.true; - } ); - - // https://github.com/ckeditor/ckeditor5-link/issues/53 - it( 'should set formView.unlinkButtonView#isVisible depending on the selection in a link or not', () => { - setModelData( editor.model, 'f[]oo' ); - - linkFeature._showPanel(); - expect( formView.unlinkButtonView.isVisible ).to.be.false; + expect( formView.urlInputView.isReadOnly ).to.be.true; + expect( formView.saveButtonView.isEnabled ).to.be.false; + expect( formView.cancelButtonView.isEnabled ).to.be.true; - setModelData( editor.model, '<$text linkHref="url">f[]oo' ); - - linkFeature._showPanel(); - expect( formView.unlinkButtonView.isVisible ).to.be.true; - - setModelData( editor.model, '<$text linkHref="url">[fo]o' ); - - linkFeature._showPanel(); - expect( formView.unlinkButtonView.isVisible ).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 @@ -217,7 +213,9 @@ describe( 'Link', () => { // Mock some leftover value **in DOM**, e.g. after previous editing. formView.urlInputView.inputView.element.value = 'leftover'; - linkFeature._showPanel(); + linkFeature._showUI(); + actionsView.fire( 'edit' ); + expect( formView.urlInputView.inputView.element.value ).to.equal( 'url' ); } ); @@ -225,11 +223,11 @@ describe( 'Link', () => { 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._showPanel(); + linkFeature._showUI(); expect( formView.urlInputView.inputView.element.value ).to.equal( '' ); } ); - describe( 'when the document is rendering', () => { + describe( 'response to view#render', () => { it( 'should not duplicate #render listeners', () => { const viewDocument = editor.editing.view; @@ -237,29 +235,29 @@ describe( 'Link', () => { const spy = testUtils.sinon.stub( balloon, 'updatePosition' ).returns( {} ); - linkFeature._showPanel(); + linkFeature._showUI(); viewDocument.render(); - linkFeature._hidePanel(); + linkFeature._hideUI(); - linkFeature._showPanel(); + 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 upon #render', () => { + 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._showPanel(); + 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.setRanges( [ Range.createFromParentsAndOffsets( text, 3, text, 3 ) ], true ); + viewDocument.selection.setRanges( [ ViewRange.createFromParentsAndOffsets( text, 3, text, 3 ) ], true ); viewDocument.render(); sinon.assert.calledOnce( spy ); @@ -269,19 +267,19 @@ describe( 'Link', () => { } ); // https://github.com/ckeditor/ckeditor5-link/issues/113 - it( 'updates the position of the panel – creating a new link, then the selection moved upon #render', () => { + 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._showPanel(); + 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.setRanges( [ Range.createFromParentsAndOffsets( text, 3, text, 3 ) ], true ); + viewDocument.selection.setRanges( [ ViewRange.createFromParentsAndOffsets( text, 3, text, 3 ) ], true ); viewDocument.render(); sinon.assert.calledOnce( spy ); @@ -291,21 +289,21 @@ describe( 'Link', () => { } ); // https://github.com/ckeditor/ckeditor5-link/issues/113 - it( 'hides of the panel – editing a link, then the selection moved out of the link upon #render', () => { + 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._showPanel(); + linkFeature._showUI(); const spyUpdate = testUtils.sinon.stub( balloon, 'updatePosition' ).returns( {} ); - const spyHide = testUtils.sinon.spy( linkFeature, '_hidePanel' ); + 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.setRanges( [ Range.createFromParentsAndOffsets( text, 1, text, 1 ) ], true ); + viewDocument.selection.setRanges( [ ViewRange.createFromParentsAndOffsets( text, 1, text, 1 ) ], true ); viewDocument.render(); sinon.assert.calledOnce( spyHide ); @@ -313,24 +311,21 @@ describe( 'Link', () => { } ); // https://github.com/ckeditor/ckeditor5-link/issues/113 - it( 'hides of the panel – editing a link, then the selection moved to another link upon #render', () => { + it( 'hides the panel – editing a link, then the selection expands', () => { const viewDocument = editor.editing.view; - setModelData( - editor.model, - '<$text linkHref="url">f[]oobar<$text linkHref="url">b[]az' - ); + setModelData( editor.model, '<$text linkHref="url">f[]oo' ); - linkFeature._showPanel(); + linkFeature._showUI(); const spyUpdate = testUtils.sinon.stub( balloon, 'updatePosition' ).returns( {} ); - const spyHide = testUtils.sinon.spy( linkFeature, '_hidePanel' ); + const spyHide = testUtils.sinon.spy( linkFeature, '_hideUI' ); const root = viewDocument.getRoot(); - const text = root.getChild( 0 ).getChild( 2 ).getChild( 0 ); + const text = root.getChild( 0 ).getChild( 0 ).getChild( 0 ); - // Move selection to b[]az. - viewDocument.selection.setRanges( [ Range.createFromParentsAndOffsets( text, 1, text, 1 ) ], true ); + // Move selection to f[o]o. + viewDocument.selection.setRanges( [ ViewRange.createFromParentsAndOffsets( text, 1, text, 2 ) ], true ); viewDocument.render(); sinon.assert.calledOnce( spyHide ); @@ -338,21 +333,21 @@ describe( 'Link', () => { } ); // https://github.com/ckeditor/ckeditor5-link/issues/113 - it( 'hides the panel – editing a link, then the selection expands upon #render', () => { + it( 'hides the panel – creating a new link, then the selection moved to another parent', () => { const viewDocument = editor.editing.view; - setModelData( editor.model, '<$text linkHref="url">f[]oo' ); + setModelData( editor.model, 'f[]oobar' ); - linkFeature._showPanel(); + linkFeature._showUI(); const spyUpdate = testUtils.sinon.stub( balloon, 'updatePosition' ).returns( {} ); - const spyHide = testUtils.sinon.spy( linkFeature, '_hidePanel' ); + const spyHide = testUtils.sinon.spy( linkFeature, '_hideUI' ); + // Fires #render. const root = viewDocument.getRoot(); - const text = root.getChild( 0 ).getChild( 0 ).getChild( 0 ); + const text = root.getChild( 1 ).getChild( 0 ); - // Move selection to f[o]o. - viewDocument.selection.setRanges( [ Range.createFromParentsAndOffsets( text, 1, text, 2 ) ], true ); + viewDocument.selection.setRanges( [ ViewRange.createFromParentsAndOffsets( text, 3, text, 3 ) ], true ); viewDocument.render(); sinon.assert.calledOnce( spyHide ); @@ -361,85 +356,56 @@ describe( 'Link', () => { } ); } ); - describe( '_hidePanel()', () => { + describe( '_hideUI()', () => { beforeEach( () => { - return balloon.add( { view: formView } ); + linkFeature._showUI(); } ); - it( 'should remove #formView from the #_balloon', () => { - linkFeature._hidePanel(); - expect( balloon.hasView( formView ) ).to.false; - } ); + it( 'should remove the UI from the balloon', () => { + expect( balloon.hasView( formView ) ).to.be.true; + expect( balloon.hasView( actionsView ) ).to.be.true; - it( 'should not focus the `editable` by default', () => { - const spy = testUtils.sinon.spy( editor.editing.view, 'focus' ); + linkFeature._hideUI(); - linkFeature._hidePanel(); - sinon.assert.notCalled( spy ); + expect( balloon.hasView( formView ) ).to.be.false; + expect( balloon.hasView( actionsView ) ).to.be.false; } ); - it( 'should not focus the `editable` when called with a `false` parameter', () => { + it( 'should focus the `editable` by default', () => { const spy = testUtils.sinon.spy( editor.editing.view, 'focus' ); - linkFeature._hidePanel( false ); - sinon.assert.notCalled( spy ); - } ); - - it( 'should focus the `editable` when called with a `true` parameter', () => { - const spy = testUtils.sinon.spy( editor.editing.view, 'focus' ); + linkFeature._hideUI(); - linkFeature._hidePanel( true ); - sinon.assert.calledOnce( spy ); + // First call is from _removeFormView. + sinon.assert.calledTwice( spy ); } ); - it( 'should not throw an error when #formView is not added to the `balloon`', () => { - linkFeature._hidePanel( true ); + it( 'should not throw an error when views are not in the `balloon`', () => { + linkFeature._hideUI(); expect( () => { - linkFeature._hidePanel( true ); + linkFeature._hideUI(); } ).to.not.throw(); } ); - it( 'should clear `render` listener from ViewDocument', () => { + it( 'should clear #render listener from the ViewDocument', () => { const spy = sinon.spy(); linkFeature.listenTo( editor.editing.view, 'render', spy ); - linkFeature._hidePanel(); + linkFeature._hideUI(); editor.editing.view.render(); sinon.assert.notCalled( spy ); } ); } ); - describe( 'link toolbar button', () => { - it( 'should register link button', () => { - expect( linkButton ).to.instanceOf( ButtonView ); - } ); - - it( 'should bind linkButtonView to 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 show the #_balloon on execute event with the selected #formView', () => { - const spy = testUtils.sinon.stub( linkFeature, '_showPanel' ).returns( {} ); - - linkButton.fire( 'execute' ); - sinon.assert.calledWithExactly( spy, true ); - } ); - } ); - describe( 'keyboard support', () => { - it( 'should show the #_balloon with selected #formView on Ctrl+K keystroke', () => { - const spy = testUtils.sinon.stub( linkFeature, '_showPanel' ).returns( {} ); + 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, @@ -449,13 +415,14 @@ describe( 'Link', () => { 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, true ); + sinon.assert.calledWithExactly( spy ); } ); it( 'should prevent default action on Ctrl+K keystroke', () => { @@ -473,7 +440,7 @@ describe( 'Link', () => { sinon.assert.calledOnce( stopPropagationSpy ); } ); - it( 'should focus the the #formView on `Tab` key press when the #_balloon is open', () => { + it( 'should focus the the #actionsView on `Tab` key press when #actionsView is visible', () => { const keyEvtData = { keyCode: keyCodes.tab, preventDefault: sinon.spy(), @@ -486,9 +453,9 @@ describe( 'Link', () => { editor.keystrokes.set( 'Tab', highestPriorityTabCallbackSpy, { priority: 'highest' } ); // Balloon is invisible, form not focused. - formView.focusTracker.isFocused = false; + actionsView.focusTracker.isFocused = false; - const spy = sinon.spy( formView, 'focus' ); + const spy = sinon.spy( actionsView, 'focus' ); editor.keystrokes.press( keyEvtData ); sinon.assert.notCalled( keyEvtData.preventDefault ); @@ -498,8 +465,10 @@ describe( 'Link', () => { sinon.assert.calledOnce( highestPriorityTabCallbackSpy ); // Balloon is visible, form focused. - linkFeature._showPanel( true ); - formView.focusTracker.isFocused = true; + linkFeature._showUI(); + testUtils.sinon.stub( linkFeature, '_areActionsVisible' ).value( true ); + + actionsView.focusTracker.isFocused = true; editor.keystrokes.press( keyEvtData ); sinon.assert.notCalled( keyEvtData.preventDefault ); @@ -509,7 +478,7 @@ describe( 'Link', () => { sinon.assert.calledTwice( highestPriorityTabCallbackSpy ); // Balloon is still visible, form not focused. - formView.focusTracker.isFocused = false; + actionsView.focusTracker.isFocused = false; editor.keystrokes.press( keyEvtData ); sinon.assert.calledOnce( keyEvtData.preventDefault ); @@ -519,8 +488,8 @@ describe( 'Link', () => { sinon.assert.calledThrice( highestPriorityTabCallbackSpy ); } ); - it( 'should hide the #_balloon after Esc key press (from editor) and not focus the editable', () => { - const spy = testUtils.sinon.spy( linkFeature, '_hidePanel' ); + 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(), @@ -528,14 +497,14 @@ describe( 'Link', () => { }; // Balloon is visible. - linkFeature._showPanel( false ); + linkFeature._showUI(); editor.keystrokes.press( keyEvtData ); sinon.assert.calledWithExactly( spy ); } ); - it( 'should not hide #_balloon after Esc key press (from editor) when #_balloon is open but is not visible', () => { - const spy = testUtils.sinon.spy( linkFeature, '_hidePanel' ); + 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: () => {}, @@ -548,42 +517,30 @@ describe( 'Link', () => { destroy: () => {} }; - linkFeature._showPanel( false ); + linkFeature._showUI(); + + // Some view precedes the link UI in the balloon. balloon.add( { view: viewMock } ); editor.keystrokes.press( keyEvtData ); sinon.assert.notCalled( spy ); } ); - - it( 'should hide the #_balloon after Esc key press (from the form) and focus the editable', () => { - const spy = testUtils.sinon.spy( linkFeature, '_hidePanel' ); - const keyEvtData = { - keyCode: keyCodes.esc, - preventDefault: sinon.spy(), - stopPropagation: sinon.spy() - }; - - linkFeature._showPanel( true ); - - formView.keystrokes.press( keyEvtData ); - sinon.assert.calledWithExactly( spy, true ); - } ); } ); describe( 'mouse support', () => { - it( 'should hide #_balloon and not focus editable on click outside the #_balloon', () => { - const spy = testUtils.sinon.spy( linkFeature, '_hidePanel' ); + it( 'should hide the UI and not focus editable upon clicking outside the UI', () => { + const spy = testUtils.sinon.spy( linkFeature, '_hideUI' ); - linkFeature._showPanel( true ); + linkFeature._showUI( true ); document.body.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) ); sinon.assert.calledWithExactly( spy ); } ); - it( 'should not hide #_balloon on click inside the #_balloon', () => { - const spy = testUtils.sinon.spy( linkFeature, '_hidePanel' ); + it( 'should not hide the UI upon clicking inside the the UI', () => { + const spy = testUtils.sinon.spy( linkFeature, '_hideUI' ); - linkFeature._showPanel( true ); + linkFeature._showUI( true ); balloon.view.element.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) ); sinon.assert.notCalled( spy ); @@ -596,66 +553,66 @@ describe( 'Link', () => { observer = editor.editing.view.getObserver( ClickObserver ); editor.model.schema.extend( '$text', { allowIn: '$root' } ); - spy = testUtils.sinon.stub( linkFeature, '_showPanel' ).returns( {} ); + spy = testUtils.sinon.stub( linkFeature, '_showUI' ).returns( {} ); } ); - it( 'should open with not selected formView when collapsed selection is inside link element', () => { + 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 open when selection exclusively encloses a link element (#1)', () => { + 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 open when selection exclusively encloses a link element (#2)', () => { + 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 not open when selection is not inside link element', () => { + it( 'should do nothing when selection is not inside link element', () => { setModelData( editor.model, '[]' ); observer.fire( 'click', { target: {} } ); sinon.assert.notCalled( spy ); } ); - it( 'should not open when selection is non-collapsed and doesn\'t enclose a link element (#1)', () => { + 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 not open when selection is non-collapsed and doesn\'t enclose a link element (#2)', () => { + 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 not open when selection is non-collapsed and doesn\'t enclose a link element (#3)', () => { + 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 not open when selection is non-collapsed and doesn\'t enclose a link element (#4)', () => { + 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 not open when selection is non-collapsed and doesn\'t enclose a link element (#5)', () => { + 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: {} } ); @@ -664,22 +621,100 @@ describe( 'Link', () => { } ); } ); - describe( 'link form', () => { + describe( 'actions 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._showPanel(); + 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.true; + 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' ); @@ -698,41 +733,38 @@ describe( 'Link', () => { formView.urlInputView.inputView.element.value = 'http://cksource.com'; formView.fire( 'submit' ); - expect( executeSpy.calledOnce ).to.true; - expect( executeSpy.calledWithExactly( 'link', 'http://cksource.com' ) ).to.true; + expect( executeSpy.calledOnce ).to.be.true; + expect( executeSpy.calledWithExactly( 'link', 'http://cksource.com' ) ).to.be.true; } ); - it( 'should hide and focus editable on formView#submit event', () => { - linkFeature._showPanel(); + it( 'should hide and reveal the #actionsView on formView#submit event', () => { + linkFeature._showUI(); formView.fire( 'submit' ); - expect( balloon.visibleView ).to.null; - expect( focusEditableSpy.calledOnce ).to.true; + expect( balloon.visibleView ).to.equal( actionsView ); + expect( focusEditableSpy.calledOnce ).to.be.true; } ); - it( 'should execute unlink command on formView#unlink event', () => { - const executeSpy = testUtils.sinon.spy( editor, 'execute' ); - - formView.fire( 'unlink' ); + it( 'should hide and reveal the #actionsView on formView#cancel event', () => { + linkFeature._showUI(); + formView.fire( 'cancel' ); - expect( executeSpy.calledOnce ).to.true; - expect( executeSpy.calledWithExactly( 'unlink' ) ).to.true; + expect( balloon.visibleView ).to.equal( actionsView ); + expect( focusEditableSpy.calledOnce ).to.be.true; } ); - it( 'should hide and focus editable on formView#unlink event', () => { - linkFeature._showPanel(); - formView.fire( 'unlink' ); - - expect( balloon.visibleView ).to.null; - expect( focusEditableSpy.calledOnce ).to.true; - } ); + it( 'should hide after Esc key press', () => { + const keyEvtData = { + keyCode: keyCodes.esc, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; - it( 'should hide and focus editable on formView#cancel event', () => { - linkFeature._showPanel(); - formView.fire( 'cancel' ); + linkFeature._showUI(); - expect( balloon.visibleView ).to.null; - expect( focusEditableSpy.calledOnce ).to.true; + formView.keystrokes.press( keyEvtData ); + expect( balloon.visibleView ).to.equal( actionsView ); + expect( focusEditableSpy.calledOnce ).to.be.true; } ); } ); } ); From 6c96ccfb3d9697bd38f8b401d7eb0602f248359b Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Wed, 31 Jan 2018 11:01:53 +0100 Subject: [PATCH 12/18] Tests: Updated link form view tests. --- tests/ui/linkformview.js | 27 +++------------------------ 1 file changed, 3 insertions(+), 24 deletions(-) diff --git a/tests/ui/linkformview.js b/tests/ui/linkformview.js index ecc56dc..13fbe6f 100644 --- a/tests/ui/linkformview.js +++ b/tests/ui/linkformview.js @@ -34,12 +34,10 @@ describe( 'LinkFormView', () => { expect( view.urlInputView ).to.be.instanceOf( View ); expect( view.saveButtonView ).to.be.instanceOf( View ); expect( view.cancelButtonView ).to.be.instanceOf( View ); - expect( view.unlinkButtonView ).to.be.instanceOf( View ); expect( view._unboundChildren.get( 0 ) ).to.equal( view.urlInputView ); expect( view._unboundChildren.get( 1 ) ).to.equal( view.saveButtonView ); expect( view._unboundChildren.get( 2 ) ).to.equal( view.cancelButtonView ); - expect( view._unboundChildren.get( 3 ) ).to.equal( view.unlinkButtonView ); } ); it( 'should create #focusTracker instance', () => { @@ -68,16 +66,6 @@ describe( 'LinkFormView', () => { expect( spy.calledOnce ).to.true; } ); - it( 'should fire `unlink` event on unlinkButtonView#execute', () => { - const spy = sinon.spy(); - - view.on( 'unlink', spy ); - - view.unlinkButtonView.fire( 'execute' ); - - expect( spy.calledOnce ).to.true; - } ); - describe( 'url input view', () => { it( 'has placeholder', () => { expect( view.urlInputView.inputView.placeholder ).to.equal( 'https://example.com' ); @@ -89,16 +77,9 @@ describe( 'LinkFormView', () => { expect( view.template.children[ 0 ] ).to.equal( view.urlInputView ); } ); - it( 'has form actions container', () => { - expect( view.template.children[ 1 ].attributes.class ).to.have.members( [ 'ck-link-form__actions' ] ); - } ); - - it( 'has form action views', () => { - const actions = view.template.children[ 1 ].children; - - expect( actions[ 0 ] ).to.equal( view.saveButtonView ); - expect( actions[ 1 ] ).to.equal( view.cancelButtonView ); - expect( actions[ 2 ] ).to.equal( view.unlinkButtonView ); + it( 'has button views', () => { + expect( view.template.children[ 1 ] ).to.equal( view.saveButtonView ); + expect( view.template.children[ 2 ] ).to.equal( view.cancelButtonView ); } ); } ); } ); @@ -109,7 +90,6 @@ describe( 'LinkFormView', () => { view.urlInputView, view.saveButtonView, view.cancelButtonView, - view.unlinkButtonView ] ); } ); @@ -122,7 +102,6 @@ describe( 'LinkFormView', () => { sinon.assert.calledWithExactly( spy.getCall( 0 ), view.urlInputView.element ); sinon.assert.calledWithExactly( spy.getCall( 1 ), view.saveButtonView.element ); sinon.assert.calledWithExactly( spy.getCall( 2 ), view.cancelButtonView.element ); - sinon.assert.calledWithExactly( spy.getCall( 3 ), view.unlinkButtonView.element ); } ); it( 'starts listening for #keystrokes coming from #element', () => { From 75df52b536ed81b6b33ba25b9ecfb6732110c6bf Mon Sep 17 00:00:00 2001 From: Aleksander Nowodzinski Date: Wed, 31 Jan 2018 11:02:20 +0100 Subject: [PATCH 13/18] Tests: Added link actions view tests. --- src/ui/linkactionsview.js | 13 +-- tests/ui/linkactionsview.js | 199 ++++++++++++++++++++++++++++++++++++ 2 files changed, 203 insertions(+), 9 deletions(-) create mode 100644 tests/ui/linkactionsview.js diff --git a/src/ui/linkactionsview.js b/src/ui/linkactionsview.js index a83076f..73dd0ac 100644 --- a/src/ui/linkactionsview.js +++ b/src/ui/linkactionsview.js @@ -12,7 +12,6 @@ import ViewCollection from '@ckeditor/ckeditor5-ui/src/viewcollection'; import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview'; -import submitHandler from '@ckeditor/ckeditor5-ui/src/bindings/submithandler'; import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker'; import FocusCycler from '@ckeditor/ckeditor5-ui/src/focuscycler'; import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler'; @@ -57,7 +56,7 @@ export default class LinkActionsView extends View { * * @member {module:ui/view~View} */ - this.previewButton = this._createPreviewButton(); + this.previewButtonView = this._createPreviewButton(); /** * The unlink button view. @@ -74,7 +73,7 @@ export default class LinkActionsView extends View { this.editButtonView = this._createButton( t( 'Edit link' ), pencilIcon, 'edit' ); /** - * Value of the "href" attribute of the link to use in the {@link #previewButton}. + * Value of the "href" attribute of the link to use in the {@link #previewButtonView}. * * @observable * @member {String} @@ -123,7 +122,7 @@ export default class LinkActionsView extends View { }, children: [ - this.previewButton, + this.previewButtonView, this.editButtonView, this.unlinkButtonView ] @@ -136,12 +135,8 @@ export default class LinkActionsView extends View { render() { super.render(); - submitHandler( { - view: this - } ); - const childViews = [ - this.previewButton, + this.previewButtonView, this.editButtonView, this.unlinkButtonView ]; diff --git a/tests/ui/linkactionsview.js b/tests/ui/linkactionsview.js new file mode 100644 index 0000000..1df4559 --- /dev/null +++ b/tests/ui/linkactionsview.js @@ -0,0 +1,199 @@ +/** + * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md. + */ + +import LinkActionsView from '../../src/ui/LinkActionsView'; +import View from '@ckeditor/ckeditor5-ui/src/view'; +import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; +import KeystrokeHandler from '@ckeditor/ckeditor5-utils/src/keystrokehandler'; +import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker'; +import FocusCycler from '@ckeditor/ckeditor5-ui/src/focuscycler'; +import ViewCollection from '@ckeditor/ckeditor5-ui/src/viewcollection'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; + +testUtils.createSinonSandbox(); + +describe( 'LinkActionsView', () => { + let view; + + beforeEach( () => { + view = new LinkActionsView( { t: val => val } ); + view.render(); + } ); + + describe( 'constructor()', () => { + it( 'should create element from template', () => { + expect( view.element.classList.contains( 'ck-link-actions' ) ).to.true; + expect( view.element.getAttribute( 'tabindex' ) ).to.equal( '-1' ); + } ); + + it( 'should create child views', () => { + expect( view.previewButtonView ).to.be.instanceOf( View ); + expect( view.unlinkButtonView ).to.be.instanceOf( View ); + expect( view.editButtonView ).to.be.instanceOf( View ); + + expect( view._unboundChildren.get( 0 ) ).to.equal( view.previewButtonView ); + expect( view._unboundChildren.get( 1 ) ).to.equal( view.editButtonView ); + expect( view._unboundChildren.get( 2 ) ).to.equal( view.unlinkButtonView ); + } ); + + it( 'should create #focusTracker instance', () => { + expect( view.focusTracker ).to.be.instanceOf( FocusTracker ); + } ); + + it( 'should create #keystrokes instance', () => { + expect( view.keystrokes ).to.be.instanceOf( KeystrokeHandler ); + } ); + + it( 'should create #_focusCycler instance', () => { + expect( view._focusCycler ).to.be.instanceOf( FocusCycler ); + } ); + + it( 'should create #_focusables view collection', () => { + expect( view._focusables ).to.be.instanceOf( ViewCollection ); + } ); + + it( 'should fire `edit` event on editButtonView#execute', () => { + const spy = sinon.spy(); + + view.on( 'edit', spy ); + + view.editButtonView.fire( 'execute' ); + + expect( spy.calledOnce ).to.true; + } ); + + it( 'should fire `unlink` event on unlinkButtonView#execute', () => { + const spy = sinon.spy(); + + view.on( 'unlink', spy ); + + view.unlinkButtonView.fire( 'execute' ); + + expect( spy.calledOnce ).to.true; + } ); + + describe( 'preview button view', () => { + it( 'is an anchor', () => { + expect( view.previewButtonView.element.tagName.toLowerCase() ).to.equal( 'a' ); + } ); + + it( 'has a CSS class', () => { + expect( view.previewButtonView.element.classList.contains( 'ck-link-actions__preview' ) ).to.be.true; + } ); + + it( 'has a target attribute', () => { + expect( view.previewButtonView.element.getAttribute( 'target' ) ).to.equal( '_blank' ); + } ); + + describe( '