8
0

linkui.js 22 KB

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