linkui.js 18 KB

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