linkui.js 16 KB

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