UNPKG

24.5 kBJavaScriptView Raw
1/**
2 * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved.
3 * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
4 */
5/**
6 * @module link/linkui
7 */
8import { Plugin } from 'ckeditor5/src/core';
9import { ClickObserver } from 'ckeditor5/src/engine';
10import { ButtonView, ContextualBalloon, clickOutsideHandler, CssTransitionDisablerMixin } from 'ckeditor5/src/ui';
11import { isWidget } from 'ckeditor5/src/widget';
12import LinkFormView from './ui/linkformview';
13import LinkActionsView from './ui/linkactionsview';
14import { addLinkProtocolIfApplicable, isLinkElement, LINK_KEYSTROKE } from './utils';
15import linkIcon from '../theme/icons/link.svg';
16const VISUAL_SELECTION_MARKER_NAME = 'link-ui';
17/**
18 * The link UI plugin. It introduces the `'link'` and `'unlink'` buttons and support for the <kbd>Ctrl+K</kbd> keystroke.
19 *
20 * It uses the
21 * {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon contextual balloon plugin}.
22 */
23export default class LinkUI extends Plugin {
24 constructor() {
25 super(...arguments);
26 /**
27 * The actions view displayed inside of the balloon.
28 */
29 this.actionsView = null;
30 /**
31 * The form view displayed inside the balloon.
32 */
33 this.formView = null;
34 }
35 /**
36 * @inheritDoc
37 */
38 static get requires() {
39 return [ContextualBalloon];
40 }
41 /**
42 * @inheritDoc
43 */
44 static get pluginName() {
45 return 'LinkUI';
46 }
47 /**
48 * @inheritDoc
49 */
50 init() {
51 const editor = this.editor;
52 editor.editing.view.addObserver(ClickObserver);
53 this._balloon = editor.plugins.get(ContextualBalloon);
54 // Create toolbar buttons.
55 this._createToolbarLinkButton();
56 this._enableBalloonActivators();
57 // Renders a fake visual selection marker on an expanded selection.
58 editor.conversion.for('editingDowncast').markerToHighlight({
59 model: VISUAL_SELECTION_MARKER_NAME,
60 view: {
61 classes: ['ck-fake-link-selection']
62 }
63 });
64 // Renders a fake visual selection marker on a collapsed selection.
65 editor.conversion.for('editingDowncast').markerToElement({
66 model: VISUAL_SELECTION_MARKER_NAME,
67 view: {
68 name: 'span',
69 classes: ['ck-fake-link-selection', 'ck-fake-link-selection_collapsed']
70 }
71 });
72 }
73 /**
74 * @inheritDoc
75 */
76 destroy() {
77 super.destroy();
78 // Destroy created UI components as they are not automatically destroyed (see ckeditor5#1341).
79 if (this.formView) {
80 this.formView.destroy();
81 }
82 if (this.actionsView) {
83 this.actionsView.destroy();
84 }
85 }
86 /**
87 * Creates views.
88 */
89 _createViews() {
90 this.actionsView = this._createActionsView();
91 this.formView = this._createFormView();
92 // Attach lifecycle actions to the the balloon.
93 this._enableUserBalloonInteractions();
94 }
95 /**
96 * Creates the {@link module:link/ui/linkactionsview~LinkActionsView} instance.
97 */
98 _createActionsView() {
99 const editor = this.editor;
100 const actionsView = new LinkActionsView(editor.locale);
101 const linkCommand = editor.commands.get('link');
102 const unlinkCommand = editor.commands.get('unlink');
103 actionsView.bind('href').to(linkCommand, 'value');
104 actionsView.editButtonView.bind('isEnabled').to(linkCommand);
105 actionsView.unlinkButtonView.bind('isEnabled').to(unlinkCommand);
106 // Execute unlink command after clicking on the "Edit" button.
107 this.listenTo(actionsView, 'edit', () => {
108 this._addFormView();
109 });
110 // Execute unlink command after clicking on the "Unlink" button.
111 this.listenTo(actionsView, 'unlink', () => {
112 editor.execute('unlink');
113 this._hideUI();
114 });
115 // Close the panel on esc key press when the **actions have focus**.
116 actionsView.keystrokes.set('Esc', (data, cancel) => {
117 this._hideUI();
118 cancel();
119 });
120 // Open the form view on Ctrl+K when the **actions have focus**..
121 actionsView.keystrokes.set(LINK_KEYSTROKE, (data, cancel) => {
122 this._addFormView();
123 cancel();
124 });
125 return actionsView;
126 }
127 /**
128 * Creates the {@link module:link/ui/linkformview~LinkFormView} instance.
129 */
130 _createFormView() {
131 const editor = this.editor;
132 const linkCommand = editor.commands.get('link');
133 const defaultProtocol = editor.config.get('link.defaultProtocol');
134 const formView = new (CssTransitionDisablerMixin(LinkFormView))(editor.locale, linkCommand);
135 formView.urlInputView.fieldView.bind('value').to(linkCommand, 'value');
136 // Form elements should be read-only when corresponding commands are disabled.
137 formView.urlInputView.bind('isEnabled').to(linkCommand, 'isEnabled');
138 formView.saveButtonView.bind('isEnabled').to(linkCommand);
139 // Execute link command after clicking the "Save" button.
140 this.listenTo(formView, 'submit', () => {
141 const { value } = formView.urlInputView.fieldView.element;
142 const parsedUrl = addLinkProtocolIfApplicable(value, defaultProtocol);
143 editor.execute('link', parsedUrl, formView.getDecoratorSwitchesState());
144 this._closeFormView();
145 });
146 // Hide the panel after clicking the "Cancel" button.
147 this.listenTo(formView, 'cancel', () => {
148 this._closeFormView();
149 });
150 // Close the panel on esc key press when the **form has focus**.
151 formView.keystrokes.set('Esc', (data, cancel) => {
152 this._closeFormView();
153 cancel();
154 });
155 return formView;
156 }
157 /**
158 * Creates a toolbar Link button. Clicking this button will show
159 * a {@link #_balloon} attached to the selection.
160 */
161 _createToolbarLinkButton() {
162 const editor = this.editor;
163 const linkCommand = editor.commands.get('link');
164 const t = editor.t;
165 editor.ui.componentFactory.add('link', locale => {
166 const button = new ButtonView(locale);
167 button.isEnabled = true;
168 button.label = t('Link');
169 button.icon = linkIcon;
170 button.keystroke = LINK_KEYSTROKE;
171 button.tooltip = true;
172 button.isToggleable = true;
173 // Bind button to the command.
174 button.bind('isEnabled').to(linkCommand, 'isEnabled');
175 button.bind('isOn').to(linkCommand, 'value', value => !!value);
176 // Show the panel on button click.
177 this.listenTo(button, 'execute', () => this._showUI(true));
178 return button;
179 });
180 }
181 /**
182 * Attaches actions that control whether the balloon panel containing the
183 * {@link #formView} should be displayed.
184 */
185 _enableBalloonActivators() {
186 const editor = this.editor;
187 const viewDocument = editor.editing.view.document;
188 // Handle click on view document and show panel when selection is placed inside the link element.
189 // Keep panel open until selection will be inside the same link element.
190 this.listenTo(viewDocument, 'click', () => {
191 const parentLink = this._getSelectedLinkElement();
192 if (parentLink) {
193 // Then show panel but keep focus inside editor editable.
194 this._showUI();
195 }
196 });
197 // Handle the `Ctrl+K` keystroke and show the panel.
198 editor.keystrokes.set(LINK_KEYSTROKE, (keyEvtData, cancel) => {
199 // Prevent focusing the search bar in FF, Chrome and Edge. See https://github.com/ckeditor/ckeditor5/issues/4811.
200 cancel();
201 if (editor.commands.get('link').isEnabled) {
202 this._showUI(true);
203 }
204 });
205 }
206 /**
207 * Attaches actions that control whether the balloon panel containing the
208 * {@link #formView} is visible or not.
209 */
210 _enableUserBalloonInteractions() {
211 // Focus the form if the balloon is visible and the Tab key has been pressed.
212 this.editor.keystrokes.set('Tab', (data, cancel) => {
213 if (this._areActionsVisible && !this.actionsView.focusTracker.isFocused) {
214 this.actionsView.focus();
215 cancel();
216 }
217 }, {
218 // Use the high priority because the link UI navigation is more important
219 // than other feature's actions, e.g. list indentation.
220 // https://github.com/ckeditor/ckeditor5-link/issues/146
221 priority: 'high'
222 });
223 // Close the panel on the Esc key press when the editable has focus and the balloon is visible.
224 this.editor.keystrokes.set('Esc', (data, cancel) => {
225 if (this._isUIVisible) {
226 this._hideUI();
227 cancel();
228 }
229 });
230 // Close on click outside of balloon panel element.
231 clickOutsideHandler({
232 emitter: this.formView,
233 activator: () => this._isUIInPanel,
234 contextElements: () => [this._balloon.view.element],
235 callback: () => this._hideUI()
236 });
237 }
238 /**
239 * Adds the {@link #actionsView} to the {@link #_balloon}.
240 *
241 * @internal
242 */
243 _addActionsView() {
244 if (!this.actionsView) {
245 this._createViews();
246 }
247 if (this._areActionsInPanel) {
248 return;
249 }
250 this._balloon.add({
251 view: this.actionsView,
252 position: this._getBalloonPositionData()
253 });
254 }
255 /**
256 * Adds the {@link #formView} to the {@link #_balloon}.
257 */
258 _addFormView() {
259 if (!this.formView) {
260 this._createViews();
261 }
262 if (this._isFormInPanel) {
263 return;
264 }
265 const editor = this.editor;
266 const linkCommand = editor.commands.get('link');
267 this.formView.disableCssTransitions();
268 this._balloon.add({
269 view: this.formView,
270 position: this._getBalloonPositionData()
271 });
272 // Select input when form view is currently visible.
273 if (this._balloon.visibleView === this.formView) {
274 this.formView.urlInputView.fieldView.select();
275 }
276 this.formView.enableCssTransitions();
277 // Make sure that each time the panel shows up, the URL field remains in sync with the value of
278 // the command. If the user typed in the input, then canceled the balloon (`urlInputView.fieldView#value` stays
279 // unaltered) and re-opened it without changing the value of the link command (e.g. because they
280 // clicked the same link), they would see the old value instead of the actual value of the command.
281 // https://github.com/ckeditor/ckeditor5-link/issues/78
282 // https://github.com/ckeditor/ckeditor5-link/issues/123
283 this.formView.urlInputView.fieldView.element.value = linkCommand.value || '';
284 }
285 /**
286 * Closes the form view. Decides whether the balloon should be hidden completely or if the action view should be shown. This is
287 * decided upon the link command value (which has a value if the document selection is in the link).
288 *
289 * Additionally, if any {@link module:link/linkconfig~LinkConfig#decorators} are defined in the editor configuration, the state of
290 * switch buttons responsible for manual decorator handling is restored.
291 */
292 _closeFormView() {
293 const linkCommand = this.editor.commands.get('link');
294 // Restore manual decorator states to represent the current model state. This case is important to reset the switch buttons
295 // when the user cancels the editing form.
296 linkCommand.restoreManualDecoratorStates();
297 if (linkCommand.value !== undefined) {
298 this._removeFormView();
299 }
300 else {
301 this._hideUI();
302 }
303 }
304 /**
305 * Removes the {@link #formView} from the {@link #_balloon}.
306 */
307 _removeFormView() {
308 if (this._isFormInPanel) {
309 // Blur the input element before removing it from DOM to prevent issues in some browsers.
310 // See https://github.com/ckeditor/ckeditor5/issues/1501.
311 this.formView.saveButtonView.focus();
312 this._balloon.remove(this.formView);
313 // Because the form has an input which has focus, the focus must be brought back
314 // to the editor. Otherwise, it would be lost.
315 this.editor.editing.view.focus();
316 this._hideFakeVisualSelection();
317 }
318 }
319 /**
320 * Shows the correct UI type. It is either {@link #formView} or {@link #actionsView}.
321 *
322 * @internal
323 */
324 _showUI(forceVisible = false) {
325 if (!this.formView) {
326 this._createViews();
327 }
328 // When there's no link under the selection, go straight to the editing UI.
329 if (!this._getSelectedLinkElement()) {
330 // Show visual selection on a text without a link when the contextual balloon is displayed.
331 // See https://github.com/ckeditor/ckeditor5/issues/4721.
332 this._showFakeVisualSelection();
333 this._addActionsView();
334 // Be sure panel with link is visible.
335 if (forceVisible) {
336 this._balloon.showStack('main');
337 }
338 this._addFormView();
339 }
340 // If there's a link under the selection...
341 else {
342 // Go to the editing UI if actions are already visible.
343 if (this._areActionsVisible) {
344 this._addFormView();
345 }
346 // Otherwise display just the actions UI.
347 else {
348 this._addActionsView();
349 }
350 // Be sure panel with link is visible.
351 if (forceVisible) {
352 this._balloon.showStack('main');
353 }
354 }
355 // Begin responding to ui#update once the UI is added.
356 this._startUpdatingUI();
357 }
358 /**
359 * Removes the {@link #formView} from the {@link #_balloon}.
360 *
361 * See {@link #_addFormView}, {@link #_addActionsView}.
362 */
363 _hideUI() {
364 if (!this._isUIInPanel) {
365 return;
366 }
367 const editor = this.editor;
368 this.stopListening(editor.ui, 'update');
369 this.stopListening(this._balloon, 'change:visibleView');
370 // Make sure the focus always gets back to the editable _before_ removing the focused form view.
371 // Doing otherwise causes issues in some browsers. See https://github.com/ckeditor/ckeditor5-link/issues/193.
372 editor.editing.view.focus();
373 // Remove form first because it's on top of the stack.
374 this._removeFormView();
375 // Then remove the actions view because it's beneath the form.
376 this._balloon.remove(this.actionsView);
377 this._hideFakeVisualSelection();
378 }
379 /**
380 * Makes the UI react to the {@link module:ui/editorui/editorui~EditorUI#event:update} event to
381 * reposition itself when the editor UI should be refreshed.
382 *
383 * See: {@link #_hideUI} to learn when the UI stops reacting to the `update` event.
384 */
385 _startUpdatingUI() {
386 const editor = this.editor;
387 const viewDocument = editor.editing.view.document;
388 let prevSelectedLink = this._getSelectedLinkElement();
389 let prevSelectionParent = getSelectionParent();
390 const update = () => {
391 const selectedLink = this._getSelectedLinkElement();
392 const selectionParent = getSelectionParent();
393 // Hide the panel if:
394 //
395 // * the selection went out of the EXISTING link element. E.g. user moved the caret out
396 // of the link,
397 // * the selection went to a different parent when creating a NEW link. E.g. someone
398 // else modified the document.
399 // * the selection has expanded (e.g. displaying link actions then pressing SHIFT+Right arrow).
400 //
401 // Note: #_getSelectedLinkElement will return a link for a non-collapsed selection only
402 // when fully selected.
403 if ((prevSelectedLink && !selectedLink) ||
404 (!prevSelectedLink && selectionParent !== prevSelectionParent)) {
405 this._hideUI();
406 }
407 // Update the position of the panel when:
408 // * link panel is in the visible stack
409 // * the selection remains in the original link element,
410 // * there was no link element in the first place, i.e. creating a new link
411 else if (this._isUIVisible) {
412 // If still in a link element, simply update the position of the balloon.
413 // If there was no link (e.g. inserting one), the balloon must be moved
414 // to the new position in the editing view (a new native DOM range).
415 this._balloon.updatePosition(this._getBalloonPositionData());
416 }
417 prevSelectedLink = selectedLink;
418 prevSelectionParent = selectionParent;
419 };
420 function getSelectionParent() {
421 return viewDocument.selection.focus.getAncestors()
422 .reverse()
423 .find((node) => node.is('element'));
424 }
425 this.listenTo(editor.ui, 'update', update);
426 this.listenTo(this._balloon, 'change:visibleView', update);
427 }
428 /**
429 * Returns `true` when {@link #formView} is in the {@link #_balloon}.
430 */
431 get _isFormInPanel() {
432 return !!this.formView && this._balloon.hasView(this.formView);
433 }
434 /**
435 * Returns `true` when {@link #actionsView} is in the {@link #_balloon}.
436 */
437 get _areActionsInPanel() {
438 return !!this.actionsView && this._balloon.hasView(this.actionsView);
439 }
440 /**
441 * Returns `true` when {@link #actionsView} is in the {@link #_balloon} and it is
442 * currently visible.
443 */
444 get _areActionsVisible() {
445 return !!this.actionsView && this._balloon.visibleView === this.actionsView;
446 }
447 /**
448 * Returns `true` when {@link #actionsView} or {@link #formView} is in the {@link #_balloon}.
449 */
450 get _isUIInPanel() {
451 return this._isFormInPanel || this._areActionsInPanel;
452 }
453 /**
454 * Returns `true` when {@link #actionsView} or {@link #formView} is in the {@link #_balloon} and it is
455 * currently visible.
456 */
457 get _isUIVisible() {
458 const visibleView = this._balloon.visibleView;
459 return !!this.formView && visibleView == this.formView || this._areActionsVisible;
460 }
461 /**
462 * Returns positioning options for the {@link #_balloon}. They control the way the balloon is attached
463 * to the target element or selection.
464 *
465 * If the selection is collapsed and inside a link element, the panel will be attached to the
466 * entire link element. Otherwise, it will be attached to the selection.
467 */
468 _getBalloonPositionData() {
469 const view = this.editor.editing.view;
470 const model = this.editor.model;
471 const viewDocument = view.document;
472 let target;
473 if (model.markers.has(VISUAL_SELECTION_MARKER_NAME)) {
474 // There are cases when we highlight selection using a marker (#7705, #4721).
475 const markerViewElements = Array.from(this.editor.editing.mapper.markerNameToElements(VISUAL_SELECTION_MARKER_NAME));
476 const newRange = view.createRange(view.createPositionBefore(markerViewElements[0]), view.createPositionAfter(markerViewElements[markerViewElements.length - 1]));
477 target = view.domConverter.viewRangeToDom(newRange);
478 }
479 else {
480 // Make sure the target is calculated on demand at the last moment because a cached DOM range
481 // (which is very fragile) can desynchronize with the state of the editing view if there was
482 // any rendering done in the meantime. This can happen, for instance, when an inline widget
483 // gets unlinked.
484 target = () => {
485 const targetLink = this._getSelectedLinkElement();
486 return targetLink ?
487 // When selection is inside link element, then attach panel to this element.
488 view.domConverter.mapViewToDom(targetLink) :
489 // Otherwise attach panel to the selection.
490 view.domConverter.viewRangeToDom(viewDocument.selection.getFirstRange());
491 };
492 }
493 return { target };
494 }
495 /**
496 * Returns the link {@link module:engine/view/attributeelement~AttributeElement} under
497 * the {@link module:engine/view/document~Document editing view's} selection or `null`
498 * if there is none.
499 *
500 * **Note**: For a non–collapsed selection, the link element is returned when **fully**
501 * selected and the **only** element within the selection boundaries, or when
502 * a linked widget is selected.
503 */
504 _getSelectedLinkElement() {
505 const view = this.editor.editing.view;
506 const selection = view.document.selection;
507 const selectedElement = selection.getSelectedElement();
508 // The selection is collapsed or some widget is selected (especially inline widget).
509 if (selection.isCollapsed || selectedElement && isWidget(selectedElement)) {
510 return findLinkElementAncestor(selection.getFirstPosition());
511 }
512 else {
513 // The range for fully selected link is usually anchored in adjacent text nodes.
514 // Trim it to get closer to the actual link element.
515 const range = selection.getFirstRange().getTrimmed();
516 const startLink = findLinkElementAncestor(range.start);
517 const endLink = findLinkElementAncestor(range.end);
518 if (!startLink || startLink != endLink) {
519 return null;
520 }
521 // Check if the link element is fully selected.
522 if (view.createRangeIn(startLink).getTrimmed().isEqual(range)) {
523 return startLink;
524 }
525 else {
526 return null;
527 }
528 }
529 }
530 /**
531 * Displays a fake visual selection when the contextual balloon is displayed.
532 *
533 * This adds a 'link-ui' marker into the document that is rendered as a highlight on selected text fragment.
534 */
535 _showFakeVisualSelection() {
536 const model = this.editor.model;
537 model.change(writer => {
538 const range = model.document.selection.getFirstRange();
539 if (model.markers.has(VISUAL_SELECTION_MARKER_NAME)) {
540 writer.updateMarker(VISUAL_SELECTION_MARKER_NAME, { range });
541 }
542 else {
543 if (range.start.isAtEnd) {
544 const startPosition = range.start.getLastMatchingPosition(({ item }) => !model.schema.isContent(item), { boundaries: range });
545 writer.addMarker(VISUAL_SELECTION_MARKER_NAME, {
546 usingOperation: false,
547 affectsData: false,
548 range: writer.createRange(startPosition, range.end)
549 });
550 }
551 else {
552 writer.addMarker(VISUAL_SELECTION_MARKER_NAME, {
553 usingOperation: false,
554 affectsData: false,
555 range
556 });
557 }
558 }
559 });
560 }
561 /**
562 * Hides the fake visual selection created in {@link #_showFakeVisualSelection}.
563 */
564 _hideFakeVisualSelection() {
565 const model = this.editor.model;
566 if (model.markers.has(VISUAL_SELECTION_MARKER_NAME)) {
567 model.change(writer => {
568 writer.removeMarker(VISUAL_SELECTION_MARKER_NAME);
569 });
570 }
571 }
572}
573/**
574 * Returns a link element if there's one among the ancestors of the provided `Position`.
575 *
576 * @param View position to analyze.
577 * @returns Link element at the position or null.
578 */
579function findLinkElementAncestor(position) {
580 return position.getAncestors().find((ancestor) => isLinkElement(ancestor)) || null;
581}