| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608 |
- /**
- * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
- * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
- */
- /**
- * @module link/linkui
- */
- import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
- import ClickObserver from '@ckeditor/ckeditor5-engine/src/view/observer/clickobserver';
- 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 support for the <kbd>Ctrl+K</kbd> 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 'LinkUI';
- }
- /**
- * @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();
- }
- /**
- * @inheritDoc
- */
- destroy() {
- super.destroy();
- // Destroy created UI components as they are not automatically destroyed (see ckeditor5#1341).
- this.formView.destroy();
- }
- /**
- * 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();
- } );
- // Open the form view on Ctrl+K when the **actions have focus**..
- actionsView.keystrokes.set( linkKeystroke, ( data, cancel ) => {
- this._addFormView();
- cancel();
- } );
- return actionsView;
- }
- /**
- * Creates the {@link module:link/ui/linkformview~LinkFormView} instance.
- *
- * @private
- * @returns {module:link/ui/linkformview~LinkFormView} The link form view instance.
- */
- _createFormView() {
- const editor = this.editor;
- const linkCommand = editor.commands.get( 'link' );
- const formView = new LinkFormView( editor.locale, linkCommand.manualDecorators );
- formView.urlInputView.fieldView.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.fieldView.value, formView.getDecoratorSwitchesState() );
- this._closeFormView();
- } );
- // Hide the panel after clicking the "Cancel" button.
- this.listenTo( formView, 'cancel', () => {
- this._closeFormView();
- } );
- // Close the panel on esc key press when the **form has focus**.
- formView.keystrokes.set( 'Esc', ( data, cancel ) => {
- this._closeFormView();
- 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();
- this._showUI( true );
- } );
- 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;
- button.isToggleable = true;
- // Bind button to the command.
- button.bind( 'isEnabled' ).to( linkCommand, 'isEnabled' );
- button.bind( 'isOn' ).to( linkCommand, 'value', value => !!value );
- // Show the panel on button click.
- this.listenTo( button, 'execute', () => this._showUI( true ) );
- 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.document;
- // 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._isUIInPanel,
- contextElements: [ this._balloon.view.element ],
- callback: () => this._hideUI()
- } );
- }
- /**
- * Adds the {@link #actionsView} to the {@link #_balloon}.
- *
- * @protected
- */
- _addActionsView() {
- if ( this._areActionsInPanel ) {
- return;
- }
- this._balloon.add( {
- view: this.actionsView,
- position: this._getBalloonPositionData()
- } );
- }
- /**
- * Adds the {@link #formView} to the {@link #_balloon}.
- *
- * @protected
- */
- _addFormView() {
- if ( this._isFormInPanel ) {
- return;
- }
- const editor = this.editor;
- const linkCommand = editor.commands.get( 'link' );
- this._balloon.add( {
- view: this.formView,
- position: this._getBalloonPositionData()
- } );
- // Select input when form view is currently visible.
- if ( this._balloon.visibleView === this.formView ) {
- this.formView.urlInputView.fieldView.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.fieldView#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.fieldView.value = linkCommand.value || '';
- }
- /**
- * Closes the form view. Decides whether the balloon should be hidden completely or if the action view should be shown. This is
- * decided upon the link command value (which has a value if the document selection is in the link).
- *
- * Additionally, if any {@link module:link/link~LinkConfig#decorators} are defined in the editor configuration, the state of
- * switch buttons responsible for manual decorator handling is restored.
- *
- * @private
- */
- _closeFormView() {
- const linkCommand = this.editor.commands.get( 'link' );
- // Restore manual decorator states to represent the current model state. This case is important to reset the switch buttons
- // when the user cancels the editing form.
- linkCommand.restoreManualDecoratorStates();
- if ( linkCommand.value !== undefined ) {
- this._removeFormView();
- } else {
- this._hideUI();
- }
- }
- /**
- * Removes the {@link #formView} from the {@link #_balloon}.
- *
- * @protected
- */
- _removeFormView() {
- if ( this._isFormInPanel ) {
- // Blur the input element before removing it from DOM to prevent issues in some browsers.
- // See https://github.com/ckeditor/ckeditor5/issues/1501.
- this.formView.saveButtonView.focus();
- 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 correct UI type. It is either {@link #formView} or {@link #actionsView}.
- *
- * @param {Boolean} forceVisible
- * @private
- */
- _showUI( forceVisible = false ) {
- // When there's no link under the selection, go straight to the editing UI.
- if ( !this._getSelectedLinkElement() ) {
- this._addActionsView();
- // Be sure panel with link is visible.
- if ( forceVisible ) {
- this._balloon.showStack( 'main' );
- }
- this._addFormView();
- }
- // If there's a link under the selection...
- else {
- // Go to the editing UI if actions are already visible.
- if ( this._areActionsVisible ) {
- this._addFormView();
- }
- // Otherwise display just the actions UI.
- else {
- this._addActionsView();
- }
- // Be sure panel with link is visible.
- if ( forceVisible ) {
- this._balloon.showStack( 'main' );
- }
- }
- // Begin responding to ui#update once the UI is added.
- this._startUpdatingUI();
- }
- /**
- * Removes the {@link #formView} from the {@link #_balloon}.
- *
- * See {@link #_addFormView}, {@link #_addActionsView}.
- *
- * @protected
- */
- _hideUI() {
- if ( !this._isUIInPanel ) {
- return;
- }
- const editor = this.editor;
- this.stopListening( editor.ui, 'update' );
- this.stopListening( this._balloon, 'change:visibleView' );
- // Make sure the focus always gets back to the editable _before_ removing the focused form view.
- // Doing otherwise causes issues in some browsers. See https://github.com/ckeditor/ckeditor5-link/issues/193.
- editor.editing.view.focus();
- // 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 );
- }
- /**
- * Makes the UI react to the {@link module:core/editor/editorui~EditorUI#event:update} event to
- * reposition itself when the editor UI should be refreshed.
- *
- * See: {@link #_hideUI} to learn when the UI stops reacting to the `update` event.
- *
- * @protected
- */
- _startUpdatingUI() {
- const editor = this.editor;
- const viewDocument = editor.editing.view.document;
- let prevSelectedLink = this._getSelectedLinkElement();
- let prevSelectionParent = getSelectionParent();
- const update = () => {
- 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:
- // * link panel is in the visible stack
- // * 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 ( this._isUIVisible ) {
- // 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 viewDocument.selection.focus.getAncestors()
- .reverse()
- .find( node => node.is( 'element' ) );
- }
- this.listenTo( editor.ui, 'update', update );
- this.listenTo( this._balloon, 'change:visibleView', update );
- }
- /**
- * 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 view = this.editor.editing.view;
- const viewDocument = view.document;
- const targetLink = this._getSelectedLinkElement();
- const target = targetLink ?
- // When selection is inside link element, then attach panel to this element.
- view.domConverter.mapViewToDom( targetLink ) :
- // Otherwise attach panel to the selection.
- view.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 view = this.editor.editing.view;
- const selection = view.document.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 ( view.createRangeIn( 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 ) );
- }
|