@ckeditor/ckeditor5-link 36.0.1 → 37.0.0-alpha.0

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