@ckeditor/ckeditor5-link
Version:
Link feature for CKEditor 5.
1,071 lines (1,070 loc) • 43.9 kB
JavaScript
/**
* @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module link/linkui
*/
import { Plugin } from 'ckeditor5/src/core.js';
import { IconLink, IconPencil, IconUnlink, IconSettings } from 'ckeditor5/src/icons.js';
import { ClickObserver } from 'ckeditor5/src/engine.js';
import { ButtonView, SwitchButtonView, ContextualBalloon, clickOutsideHandler, CssTransitionDisablerMixin, MenuBarMenuListItemButtonView, ToolbarView } from 'ckeditor5/src/ui.js';
import { Collection } from 'ckeditor5/src/utils.js';
import { isWidget } from 'ckeditor5/src/widget.js';
import LinkEditing from './linkediting.js';
import LinkPreviewButtonView from './ui/linkpreviewbuttonview.js';
import LinkFormView from './ui/linkformview.js';
import LinkProviderItemsView from './ui/linkprovideritemsview.js';
import LinkPropertiesView from './ui/linkpropertiesview.js';
import LinkButtonView from './ui/linkbuttonview.js';
import { addLinkProtocolIfApplicable, ensureSafeUrl, isLinkElement, extractTextFromLinkRange, LINK_KEYSTROKE } from './utils.js';
import '../theme/linktoolbar.css';
const VISUAL_SELECTION_MARKER_NAME = 'link-ui';
/**
* 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}.
*/
export default class LinkUI extends Plugin {
/**
* The toolbar view displayed inside of the balloon.
*/
toolbarView = null;
/**
* The form view displayed inside the balloon.
*/
formView = null;
/**
* The view displaying links list.
*/
linkProviderItemsView = null;
/**
* The form view displaying properties link settings.
*/
propertiesView = null;
/**
* The contextual balloon plugin instance.
*/
_balloon;
/**
* The collection of the link providers.
*/
_linksProviders = new Collection();
/**
* @inheritDoc
*/
static get requires() {
return [ContextualBalloon, LinkEditing];
}
/**
* @inheritDoc
*/
static get pluginName() {
return 'LinkUI';
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @inheritDoc
*/
init() {
const editor = this.editor;
const t = this.editor.t;
this.set('selectedLinkableText', undefined);
editor.editing.view.addObserver(ClickObserver);
this._balloon = editor.plugins.get(ContextualBalloon);
// Create toolbar buttons.
this._registerComponents();
this._registerEditingOpeners();
this._enableBalloonActivators();
// Renders a fake visual selection marker on an expanded selection.
editor.conversion.for('editingDowncast').markerToHighlight({
model: VISUAL_SELECTION_MARKER_NAME,
view: {
classes: ['ck-fake-link-selection']
}
});
// Renders a fake visual selection marker on a collapsed selection.
editor.conversion.for('editingDowncast').markerToElement({
model: VISUAL_SELECTION_MARKER_NAME,
view: (data, { writer }) => {
if (!data.markerRange.isCollapsed) {
return null;
}
const markerElement = writer.createUIElement('span');
writer.addClass(['ck-fake-link-selection', 'ck-fake-link-selection_collapsed'], markerElement);
return markerElement;
}
});
// Add the information about the keystrokes to the accessibility database.
editor.accessibility.addKeystrokeInfos({
keystrokes: [
{
label: t('Create link'),
keystroke: LINK_KEYSTROKE
},
{
label: t('Move out of a link'),
keystroke: [
['arrowleft', 'arrowleft'],
['arrowright', 'arrowright']
]
}
]
});
}
/**
* @inheritDoc
*/
destroy() {
super.destroy();
// Destroy created UI components as they are not automatically destroyed (see ckeditor5#1341).
if (this.propertiesView) {
this.propertiesView.destroy();
}
if (this.formView) {
this.formView.destroy();
}
if (this.toolbarView) {
this.toolbarView.destroy();
}
if (this.linkProviderItemsView) {
this.linkProviderItemsView.destroy();
}
}
/**
* Registers list of buttons below the link form view that
* open a list of links provided by the clicked provider.
*/
registerLinksListProvider(provider) {
const insertIndex = this._linksProviders
.filter(existing => (existing.order || 0) <= (provider.order || 0))
.length;
this._linksProviders.add(provider, insertIndex);
}
/**
* Creates views.
*/
_createViews() {
const linkCommand = this.editor.commands.get('link');
this.toolbarView = this._createToolbarView();
this.formView = this._createFormView();
if (linkCommand.manualDecorators.length) {
this.propertiesView = this._createPropertiesView();
}
// Attach lifecycle actions to the the balloon.
this._enableUserBalloonInteractions();
}
/**
* Creates the ToolbarView instance.
*/
_createToolbarView() {
const editor = this.editor;
const toolbarView = new ToolbarView(editor.locale);
const linkCommand = editor.commands.get('link');
toolbarView.class = 'ck-link-toolbar';
// Remove the linkProperties button if there are no manual decorators, as it would be useless.
let toolbarItems = editor.config.get('link.toolbar');
if (!linkCommand.manualDecorators.length) {
toolbarItems = toolbarItems.filter(item => item !== 'linkProperties');
}
toolbarView.fillFromConfig(toolbarItems, editor.ui.componentFactory);
// Close the panel on esc key press when the **link toolbar have focus**.
toolbarView.keystrokes.set('Esc', (data, cancel) => {
this._hideUI();
cancel();
});
// Open the form view on Ctrl+K when the **link toolbar have focus**..
toolbarView.keystrokes.set(LINK_KEYSTROKE, (data, cancel) => {
this._addFormView();
cancel();
});
// Register the toolbar, so it becomes available for Alt+F10 and Esc navigation.
// TODO this should be registered earlier to be able to open this toolbar without previously opening it by click or Ctrl+K
editor.ui.addToolbar(toolbarView, {
isContextual: true,
beforeFocus: () => {
if (this._getSelectedLinkElement() && !this._isToolbarVisible) {
this._showUI(true);
}
},
afterBlur: () => {
this._hideUI(false);
}
});
return toolbarView;
}
/**
* Creates the {@link module:link/ui/linkformview~LinkFormView} instance.
*/
_createFormView() {
const editor = this.editor;
const t = editor.locale.t;
const linkCommand = editor.commands.get('link');
const defaultProtocol = editor.config.get('link.defaultProtocol');
const formView = new (CssTransitionDisablerMixin(LinkFormView))(editor.locale, getFormValidators(editor));
formView.displayedTextInputView.bind('isEnabled').to(this, 'selectedLinkableText', value => value !== undefined);
// Form elements should be read-only when corresponding commands are disabled.
formView.urlInputView.bind('isEnabled').to(linkCommand, 'isEnabled');
// Disable the "save" button if the command is disabled.
formView.saveButtonView.bind('isEnabled').to(linkCommand, 'isEnabled');
// Change the "Save" button label depending on the command state.
formView.saveButtonView.bind('label').to(linkCommand, 'value', value => value ? t('Update') : t('Insert'));
// Execute link command after clicking the "Save" button.
this.listenTo(formView, 'submit', () => {
if (formView.isValid()) {
const url = formView.urlInputView.fieldView.element.value;
const parsedUrl = addLinkProtocolIfApplicable(url, defaultProtocol);
const displayedText = formView.displayedTextInputView.fieldView.element.value;
editor.execute('link', parsedUrl, this._getDecoratorSwitchesState(), displayedText !== this.selectedLinkableText ? displayedText : undefined);
this._closeFormView();
}
});
// Update balloon position when form error changes.
this.listenTo(formView.urlInputView, 'change:errorText', () => {
editor.ui.update();
});
// 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();
});
// Watch adding new link providers and add them to the buttons list.
formView.providersListChildren.bindTo(this._linksProviders).using(provider => this._createLinksListProviderButton(provider));
return formView;
}
/**
* Creates a sorted array of buttons with link names.
*/
_createLinkProviderListView(provider) {
return provider.getListItems().map(({ href, label, icon }) => {
const buttonView = new ButtonView();
buttonView.set({
label,
icon,
tooltip: false,
withText: true
});
buttonView.on('execute', () => {
this.formView.resetFormStatus();
this.formView.urlInputView.fieldView.value = href;
// Set focus to the editing view to prevent from losing it while current view is removed.
this.editor.editing.view.focus();
this._removeLinksProviderView();
// Set the focus to the URL input field.
this.formView.focus();
});
return buttonView;
});
}
/**
* Creates a view for links provider.
*/
_createLinkProviderItemsView(provider) {
const editor = this.editor;
const t = editor.locale.t;
const view = new LinkProviderItemsView(editor.locale);
const { emptyListPlaceholder, label } = provider;
view.emptyListPlaceholder = emptyListPlaceholder || t('No links available');
view.title = label;
// Hide the panel after clicking the "Cancel" button.
this.listenTo(view, 'cancel', () => {
// Set focus to the editing view to prevent from losing it while current view is removed.
editor.editing.view.focus();
this._removeLinksProviderView();
// Set the focus to the URL input field.
this.formView.focus();
});
return view;
}
/**
* Creates the {@link module:link/ui/linkpropertiesview~LinkPropertiesView} instance.
*/
_createPropertiesView() {
const editor = this.editor;
const linkCommand = this.editor.commands.get('link');
const view = new (CssTransitionDisablerMixin(LinkPropertiesView))(editor.locale);
// Hide the panel after clicking the back button.
this.listenTo(view, 'back', () => {
// Move focus back to the editing view to prevent from losing it while current view is removed.
editor.editing.view.focus();
this._removePropertiesView();
});
view.listChildren.bindTo(linkCommand.manualDecorators).using(manualDecorator => {
const button = new SwitchButtonView(editor.locale);
button.set({
label: manualDecorator.label,
withText: true
});
button.bind('isOn').toMany([manualDecorator, linkCommand], 'value', (decoratorValue, commandValue) => {
return commandValue === undefined && decoratorValue === undefined ?
!!manualDecorator.defaultValue :
!!decoratorValue;
});
button.on('execute', () => {
manualDecorator.set('value', !button.isOn);
editor.execute('link', linkCommand.value, this._getDecoratorSwitchesState());
});
return button;
});
return view;
}
/**
* Obtains the state of the manual decorators.
*/
_getDecoratorSwitchesState() {
const linkCommand = this.editor.commands.get('link');
return Array
.from(linkCommand.manualDecorators)
.reduce((accumulator, manualDecorator) => {
const value = linkCommand.value === undefined && manualDecorator.value === undefined ?
manualDecorator.defaultValue :
manualDecorator.value;
return {
...accumulator,
[manualDecorator.id]: !!value
};
}, {});
}
/**
* Registers listeners used in editing plugin, used to open links.
*/
_registerEditingOpeners() {
const linkEditing = this.editor.plugins.get(LinkEditing);
linkEditing._registerLinkOpener(href => {
const match = this._getLinkProviderLinkByHref(href);
if (!match) {
return false;
}
const { item, provider } = match;
if (provider.navigate) {
return provider.navigate(item);
}
return false;
});
}
/**
* Registers components in the ComponentFactory.
*/
_registerComponents() {
const editor = this.editor;
editor.ui.componentFactory.add('link', () => {
const button = this._createButton(ButtonView);
button.set({
tooltip: true
});
return button;
});
editor.ui.componentFactory.add('menuBar:link', () => {
const button = this._createButton(MenuBarMenuListItemButtonView);
button.set({
role: 'menuitemcheckbox'
});
return button;
});
editor.ui.componentFactory.add('linkPreview', locale => {
const button = new LinkPreviewButtonView(locale);
const allowedProtocols = editor.config.get('link.allowedProtocols');
const linkCommand = editor.commands.get('link');
const t = locale.t;
button.bind('isEnabled').to(linkCommand, 'value', href => !!href);
button.bind('href').to(linkCommand, 'value', href => {
return href && ensureSafeUrl(href, allowedProtocols);
});
const setHref = (href) => {
if (!href) {
button.label = undefined;
button.icon = undefined;
button.tooltip = t('Open link in new tab');
return;
}
const selectedLinksProviderLink = this._getLinkProviderLinkByHref(href);
if (selectedLinksProviderLink) {
const { label, tooltip, icon } = selectedLinksProviderLink.item;
button.label = label;
button.tooltip = tooltip || false;
button.icon = icon;
}
else {
button.label = href;
button.icon = undefined;
button.tooltip = t('Open link in new tab');
}
};
setHref(linkCommand.value);
this.listenTo(linkCommand, 'change:value', (evt, name, href) => {
setHref(href);
});
this.listenTo(button, 'navigate', (evt, href, cancel) => {
const selectedLinksProviderLink = this._getLinkProviderLinkByHref(href);
if (!selectedLinksProviderLink) {
return;
}
const { provider, item } = selectedLinksProviderLink;
const { navigate } = provider;
if (navigate && navigate(item)) {
evt.stop();
cancel();
}
});
return button;
});
editor.ui.componentFactory.add('unlink', locale => {
const unlinkCommand = editor.commands.get('unlink');
const button = new ButtonView(locale);
const t = locale.t;
button.set({
label: t('Unlink'),
icon: IconUnlink,
tooltip: true
});
button.bind('isEnabled').to(unlinkCommand);
this.listenTo(button, 'execute', () => {
editor.execute('unlink');
this._hideUI();
});
return button;
});
editor.ui.componentFactory.add('editLink', locale => {
const linkCommand = editor.commands.get('link');
const button = new ButtonView(locale);
const t = locale.t;
button.set({
label: t('Edit link'),
icon: IconPencil,
tooltip: true
});
button.bind('isEnabled').to(linkCommand);
this.listenTo(button, 'execute', () => {
this._addFormView();
});
return button;
});
editor.ui.componentFactory.add('linkProperties', locale => {
const linkCommand = editor.commands.get('link');
const button = new ButtonView(locale);
const t = locale.t;
button.set({
label: t('Link properties'),
icon: IconSettings,
tooltip: true
});
button.bind('isEnabled').to(linkCommand, 'isEnabled', linkCommand, 'value', linkCommand, 'manualDecorators', (isEnabled, href, manualDecorators) => isEnabled && !!href && manualDecorators.length > 0);
this.listenTo(button, 'execute', () => {
this._addPropertiesView();
});
return button;
});
}
/**
* Creates a links button view.
*/
_createLinksListProviderButton(linkProvider) {
const locale = this.editor.locale;
const linksButton = new LinkButtonView(locale);
linksButton.set({
label: linkProvider.label
});
this.listenTo(linksButton, 'execute', () => {
this._showLinksProviderView(linkProvider);
});
return linksButton;
}
/**
* Creates a button for link command to use either in toolbar or in menu bar.
*/
_createButton(ButtonClass) {
const editor = this.editor;
const locale = editor.locale;
const command = editor.commands.get('link');
const view = new ButtonClass(editor.locale);
const t = locale.t;
view.set({
label: t('Link'),
icon: IconLink,
keystroke: LINK_KEYSTROKE,
isToggleable: true
});
view.bind('isEnabled').to(command, 'isEnabled');
view.bind('isOn').to(command, 'value', value => !!value);
// Show the panel on button click.
this.listenTo(view, 'execute', () => {
editor.editing.view.scrollToTheSelection();
this._showUI(true);
// Open the form view on-top of the toolbar view if it's already visible.
// It should be visible every time the link is selected.
if (this._getSelectedLinkElement()) {
this._addFormView();
}
});
return view;
}
/**
* Attaches actions that control whether the balloon panel containing the
* {@link #formView} should be displayed.
*/
_enableBalloonActivators() {
const editor = this.editor;
const viewDocument = 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();
}
});
// Handle the `Ctrl+K` keystroke and show the panel.
editor.keystrokes.set(LINK_KEYSTROKE, (keyEvtData, cancel) => {
// Prevent focusing the search bar in FF, Chrome and Edge. See https://github.com/ckeditor/ckeditor5/issues/4811.
cancel();
if (editor.commands.get('link').isEnabled) {
editor.editing.view.scrollToTheSelection();
this._showUI(true);
}
});
}
/**
* Attaches actions that control whether the balloon panel containing the
* {@link #formView} is visible or not.
*/
_enableUserBalloonInteractions() {
// Focus the form if the balloon is visible and the Tab key has been pressed.
this.editor.keystrokes.set('Tab', (data, cancel) => {
if (this._isToolbarVisible && !this.toolbarView.focusTracker.isFocused) {
this.toolbarView.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: () => {
// Focusing on the editable during a click outside the balloon panel might
// cause the selection to move to the beginning of the editable, so we avoid
// focusing on it during this action.
// See: https://github.com/ckeditor/ckeditor5/issues/18253
this._hideUI(false);
}
});
}
/**
* Adds the {@link #toolbarView} to the {@link #_balloon}.
*
* @internal
*/
_addToolbarView() {
if (!this.toolbarView) {
this._createViews();
}
if (this._isToolbarInPanel) {
return;
}
this._balloon.add({
view: this.toolbarView,
position: this._getBalloonPositionData(),
balloonClassName: 'ck-toolbar-container'
});
}
/**
* Adds the {@link #formView} to the {@link #_balloon}.
*/
_addFormView() {
if (!this.formView) {
this._createViews();
}
if (this._isFormInPanel) {
return;
}
const linkCommand = this.editor.commands.get('link');
this.formView.disableCssTransitions();
this.formView.resetFormStatus();
this.formView.backButtonView.isVisible = linkCommand.isEnabled && !!linkCommand.value;
this._balloon.add({
view: this.formView,
position: this._getBalloonPositionData()
});
// Make sure that each time the panel shows up, the fields 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.selectedLinkableText = this._getSelectedLinkableText();
this.formView.displayedTextInputView.fieldView.value = this.selectedLinkableText || '';
this.formView.urlInputView.fieldView.value = linkCommand.value || '';
// Select input when form view is currently visible.
if (this._balloon.visibleView === this.formView) {
this.formView.urlInputView.fieldView.select();
}
this.formView.enableCssTransitions();
}
/**
* Adds the {@link #propertiesView} to the {@link #_balloon}.
*/
_addPropertiesView() {
if (!this.propertiesView) {
this._createViews();
}
if (this._arePropertiesInPanel) {
return;
}
this.propertiesView.disableCssTransitions();
this._balloon.add({
view: this.propertiesView,
position: this._getBalloonPositionData()
});
this.propertiesView.enableCssTransitions();
this.propertiesView.focus();
}
/**
* Shows the view with links provided by the given provider.
*/
_showLinksProviderView(provider) {
if (this.linkProviderItemsView) {
this._removeLinksProviderView();
}
this.linkProviderItemsView = this._createLinkProviderItemsView(provider);
this._addLinkProviderItemsView(provider);
}
/**
* Adds the {@link #linkProviderItemsView} to the {@link #_balloon}.
*/
_addLinkProviderItemsView(provider) {
// Clear the collection of links.
this.linkProviderItemsView.listChildren.clear();
// Add links to the collection.
this.linkProviderItemsView.listChildren.addMany(this._createLinkProviderListView(provider));
this._balloon.add({
view: this.linkProviderItemsView,
position: this._getBalloonPositionData()
});
this.linkProviderItemsView.focus();
}
/**
* 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).
*/
_closeFormView() {
const linkCommand = this.editor.commands.get('link');
this.selectedLinkableText = undefined;
if (linkCommand.value !== undefined) {
this._removeFormView();
}
else {
this._hideUI();
}
}
/**
* Removes the {@link #propertiesView} from the {@link #_balloon}.
*/
_removePropertiesView() {
if (this._arePropertiesInPanel) {
this._balloon.remove(this.propertiesView);
}
}
/**
* Removes the {@link #linkProviderItemsView} from the {@link #_balloon}.
*/
_removeLinksProviderView() {
if (this._isLinksListInPanel) {
this._balloon.remove(this.linkProviderItemsView);
}
}
/**
* Removes the {@link #formView} from the {@link #_balloon}.
*/
_removeFormView(updateFocus = true) {
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();
// Reset fields to update the state of the submit button.
this.formView.displayedTextInputView.fieldView.reset();
this.formView.urlInputView.fieldView.reset();
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.
if (updateFocus) {
this.editor.editing.view.focus();
}
this._hideFakeVisualSelection();
}
}
/**
* Shows the correct UI type. It is either {@link #formView} or {@link #toolbarView}.
*
* @internal
*/
_showUI(forceVisible = false) {
if (!this.formView) {
this._createViews();
}
// When there's no link under the selection, go straight to the editing UI.
if (!this._getSelectedLinkElement()) {
// Show visual selection on a text without a link when the contextual balloon is displayed.
// See https://github.com/ckeditor/ckeditor5/issues/4721.
this._showFakeVisualSelection();
this._addToolbarView();
// 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 toolbar is already visible.
if (this._isToolbarVisible) {
this._addFormView();
}
// Otherwise display just the toolbar.
else {
this._addToolbarView();
}
// 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 #_addToolbarView}.
*/
_hideUI(updateFocus = true) {
const editor = this.editor;
if (!this._isUIInPanel) {
return;
}
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.
if (updateFocus) {
editor.editing.view.focus();
}
// If the links view is visible, remove it because it can be on top of the stack.
this._removeLinksProviderView();
// If the properties form view is visible, remove it because it can be on top of the stack.
this._removePropertiesView();
// Then remove the form view because it's beneath the properties form.
this._removeFormView(updateFocus);
// Finally, remove the link toolbar view because it's last in the stack.
if (this._isToolbarInPanel) {
this._balloon.remove(this.toolbarView);
}
this._hideFakeVisualSelection();
}
/**
* Makes the UI react to the {@link module:ui/editorui/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.
*/
_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 toolbar 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 #propertiesView} is in the {@link #_balloon}.
*/
get _arePropertiesInPanel() {
return !!this.propertiesView && this._balloon.hasView(this.propertiesView);
}
/**
* Returns `true` when {@link #linkProviderItemsView} is in the {@link #_balloon}.
*/
get _isLinksListInPanel() {
return !!this.linkProviderItemsView && this._balloon.hasView(this.linkProviderItemsView);
}
/**
* Returns `true` when {@link #formView} is in the {@link #_balloon}.
*/
get _isFormInPanel() {
return !!this.formView && this._balloon.hasView(this.formView);
}
/**
* Returns `true` when {@link #toolbarView} is in the {@link #_balloon}.
*/
get _isToolbarInPanel() {
return !!this.toolbarView && this._balloon.hasView(this.toolbarView);
}
/**
* Returns `true` when {@link #propertiesView} is in the {@link #_balloon} and it is
* currently visible.
*/
get _isPropertiesVisible() {
return !!this.propertiesView && this._balloon.visibleView === this.propertiesView;
}
/**
* Returns `true` when {@link #formView} is in the {@link #_balloon} and it is
* currently visible.
*/
get _isFormVisible() {
return !!this.formView && this._balloon.visibleView == this.formView;
}
/**
* Returns `true` when {@link #toolbarView} is in the {@link #_balloon} and it is
* currently visible.
*/
get _isToolbarVisible() {
return !!this.toolbarView && this._balloon.visibleView === this.toolbarView;
}
/**
* Returns `true` when {@link #propertiesView}, {@link #toolbarView}, {@link #linkProviderItemsView}
* or {@link #formView} is in the {@link #_balloon}.
*/
get _isUIInPanel() {
return this._arePropertiesInPanel || this._isLinksListInPanel || this._isFormInPanel || this._isToolbarInPanel;
}
/**
* Returns `true` when {@link #propertiesView}, {@link #linkProviderItemsView}, {@link #toolbarView}
* or {@link #formView} is in the {@link #_balloon} and it is currently visible.
*/
get _isUIVisible() {
return this._isPropertiesVisible || this._isLinksListInPanel || this._isFormVisible || this._isToolbarVisible;
}
/**
* 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.
*/
_getBalloonPositionData() {
const view = this.editor.editing.view;
const viewDocument = view.document;
const model = this.editor.model;
if (model.markers.has(VISUAL_SELECTION_MARKER_NAME)) {
// There are cases when we highlight selection using a marker (#7705, #4721).
const markerViewElements = this.editor.editing.mapper.markerNameToElements(VISUAL_SELECTION_MARKER_NAME);
// Marker could be removed by link text override and end up in the graveyard.
if (markerViewElements) {
const markerViewElementsArray = Array.from(markerViewElements);
const newRange = view.createRange(view.createPositionBefore(markerViewElementsArray[0]), view.createPositionAfter(markerViewElementsArray[markerViewElementsArray.length - 1]));
return {
target: view.domConverter.viewRangeToDom(newRange)
};
}
}
// Make sure the target is calculated on demand at the last moment because a cached DOM range
// (which is very fragile) can desynchronize with the state of the editing view if there was
// any rendering done in the meantime. This can happen, for instance, when an inline widget
// gets unlinked.
return {
target: () => {
const targetLink = this._getSelectedLinkElement();
return 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());
}
};
}
/**
* 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 returned when **fully**
* selected and the **only** element within the selection boundaries, or when
* a linked widget is selected.
*/
_getSelectedLinkElement() {
const view = this.editor.editing.view;
const selection = view.document.selection;
const selectedElement = selection.getSelectedElement();
// The selection is collapsed or some widget is selected (especially inline widget).
if (selection.isCollapsed || selectedElement && isWidget(selectedElement)) {
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 selected link text content.
* If link is not selected it returns the selected text.
* If selection or link includes non text node (inline object or block) then returns undefined.
*/
_getSelectedLinkableText() {
const model = this.editor.model;
const editing = this.editor.editing;
const selectedLink = this._getSelectedLinkElement();
if (!selectedLink) {
return extractTextFromLinkRange(model.document.selection.getFirstRange());
}
const viewLinkRange = editing.view.createRangeOn(selectedLink);
const linkRange = editing.mapper.toModelRange(viewLinkRange);
return extractTextFromLinkRange(linkRange);
}
/**
* Returns a provider by its URL.
*
* @param href URL of the link.
* @returns Link provider and item or `null` if not found.
*/
_getLinkProviderLinkByHref(href) {
if (!href) {
return null;
}
for (const provider of this._linksProviders) {
const item = provider.getItem ?
provider.getItem(href) :
provider.getListItems().find(item => item.href === href);
if (item) {
return { provider, item };
}
}
return null;
}
/**
* Displays a fake visual selection when the contextual balloon is displayed.
*
* This adds a 'link-ui' marker into the document that is rendered as a highlight on selected text fragment.
*/
_showFakeVisualSelection() {
const model = this.editor.model;
model.change(writer => {
const range = model.document.selection.getFirstRange();
if (model.markers.has(VISUAL_SELECTION_MARKER_NAME)) {
writer.updateMarker(VISUAL_SELECTION_MARKER_NAME, { range });
}
else {
if (range.start.isAtEnd) {
const startPosition = range.start.getLastMatchingPosition(({ item }) => !model.schema.isContent(item), { boundaries: range });
writer.addMarker(VISUAL_SELECTION_MARKER_NAME, {
usingOperation: false,
affectsData: false,
range: writer.createRange(startPosition, range.end)
});
}
else {
writer.addMarker(VISUAL_SELECTION_MARKER_NAME, {
usingOperation: false,
affectsData: false,
range
});
}
}
});
}
/**
* Hides the fake visual selection created in {@link #_showFakeVisualSelection}.
*/
_hideFakeVisualSelection() {
const model = this.editor.model;
if (model.markers.has(VISUAL_SELECTION_MARKER_NAME)) {
model.change(writer => {
writer.removeMarker(VISUAL_SELECTION_MARKER_NAME);
});
}
}
}
/**
* Returns a link element if there's one among the ancestors of the provided `Position`.
*
* @param View position to analyze.
* @returns Link element at the position or null.
*/
function findLinkElementAncestor(position) {
return position.getAncestors().find((ancestor) => isLinkElement(ancestor)) || null;
}
/**
* Returns link form validation callbacks.
*
* @param editor Editor instance.
*/
function getFormValidators(editor) {
const t = editor.t;
const allowCreatingEmptyLinks = editor.config.get('link.allowCreatingEmptyLinks');
return [
form => {
if (!allowCreatingEmptyLinks && !form.url.length) {
return t('Link URL must not be empty.');
}
}
];
}