link.js 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323
  1. /**
  2. * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md.
  4. */
  5. /**
  6. * @module link/link
  7. */
  8. import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
  9. import ClickObserver from '@ckeditor/ckeditor5-engine/src/view/observer/clickobserver';
  10. import LinkEngine from './linkengine';
  11. import LinkElement from './linkelement';
  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 linkIcon from '../theme/icons/link.svg';
  16. import unlinkIcon from '../theme/icons/unlink.svg';
  17. import '../theme/theme.scss';
  18. /**
  19. * The link feature. It introduces the Link and Unlink buttons and the <kbd>Ctrl+K</kbd> keystroke.
  20. * Link UI is displayed using {@link module:core/editor/editorui~EditorUI#balloon}.
  21. *
  22. * It uses the {@link module:link/linkengine~LinkEngine link engine feature}.
  23. *
  24. * @extends module:core/plugin~Plugin
  25. */
  26. export default class Link extends Plugin {
  27. /**
  28. * @inheritDoc
  29. */
  30. static get requires() {
  31. return [ LinkEngine ];
  32. }
  33. /**
  34. * @inheritDoc
  35. */
  36. static get pluginName() {
  37. return 'link/link';
  38. }
  39. /**
  40. * @inheritDoc
  41. */
  42. init() {
  43. // Register click observer to handle `click` event on the view document.
  44. this.editor.editing.view.addObserver( ClickObserver );
  45. /**
  46. * The form view inside {@link #balloonPanelView}.
  47. *
  48. * @member {module:link/ui/linkformview~LinkFormView}
  49. */
  50. this.formView = this._createForm();
  51. // Create toolbar buttons.
  52. this._createToolbarLinkButton();
  53. this._createToolbarUnlinkButton();
  54. // Attach lifecycle actions to the link balloon.
  55. this._attachActions();
  56. }
  57. /**
  58. * Returns `true` when link panel is added to the {@link module:core/editor/editorui~EditorUI#balloon} stack.
  59. *
  60. * @private
  61. * @returns {Boolean}
  62. */
  63. get _isInStack() {
  64. return this.editor.ui.balloon.isPanelInStack( this.formView );
  65. }
  66. /**
  67. * Returns `true` when link panel is currently visible in {@link module:core/editor/editorui~EditorUI#balloon}.
  68. *
  69. * @private
  70. * @returns {Boolean}
  71. */
  72. get _isVisible() {
  73. const balloon = this.editor.ui.balloon;
  74. return balloon.visible && balloon.visible.view === this.formView;
  75. }
  76. /**
  77. * Creates the {@link module:link/ui/linkformview~LinkFormView} instance.
  78. *
  79. * @private
  80. * @returns {module:link/ui/linkformview~LinkFormView} Link form instance.
  81. */
  82. _createForm() {
  83. const editor = this.editor;
  84. const formView = new LinkFormView( editor.locale );
  85. formView.urlInputView.bind( 'value' ).to( editor.commands.get( 'link' ), 'value' );
  86. // Execute link command after clicking on formView `Save` button.
  87. this.listenTo( formView, 'submit', () => {
  88. editor.execute( 'link', formView.urlInputView.inputView.element.value );
  89. this._hidePanel( true );
  90. } );
  91. // Execute unlink command after clicking on formView `Unlink` button.
  92. this.listenTo( formView, 'unlink', () => {
  93. editor.execute( 'unlink' );
  94. this._hidePanel( true );
  95. } );
  96. // Hide balloon panel after clicking on formView `Cancel` button.
  97. this.listenTo( formView, 'cancel', () => this._hidePanel( true ) );
  98. // Close the panel on esc key press when the form has focus.
  99. formView.keystrokes.set( 'Esc', ( data, cancel ) => {
  100. this._hidePanel( true );
  101. cancel();
  102. } );
  103. return formView;
  104. }
  105. /**
  106. * Creates a toolbar link button. Clicking this button will show
  107. * {@link #balloonPanelView} attached to the selection.
  108. *
  109. * @private
  110. */
  111. _createToolbarLinkButton() {
  112. const editor = this.editor;
  113. const linkCommand = editor.commands.get( 'link' );
  114. const t = editor.t;
  115. // Handle `Ctrl+K` keystroke and show panel.
  116. editor.keystrokes.set( 'CTRL+K', () => this._showPanel( true ) );
  117. editor.ui.componentFactory.add( 'link', ( locale ) => {
  118. const button = new ButtonView( locale );
  119. button.isEnabled = true;
  120. button.label = t( 'Link' );
  121. button.icon = linkIcon;
  122. button.keystroke = 'CTRL+K';
  123. button.tooltip = true;
  124. // Bind button to the command.
  125. button.bind( 'isEnabled' ).to( linkCommand, 'isEnabled' );
  126. // Show the panel on button click.
  127. this.listenTo( button, 'execute', () => this._showPanel( true ) );
  128. return button;
  129. } );
  130. }
  131. /**
  132. * Creates a toolbar unlink button. Clicking this button will unlink
  133. * the selected link.
  134. *
  135. * @private
  136. */
  137. _createToolbarUnlinkButton() {
  138. const editor = this.editor;
  139. const t = editor.t;
  140. const unlinkCommand = editor.commands.get( 'unlink' );
  141. editor.ui.componentFactory.add( 'unlink', ( locale ) => {
  142. const button = new ButtonView( locale );
  143. button.isEnabled = false;
  144. button.label = t( 'Unlink' );
  145. button.icon = unlinkIcon;
  146. button.tooltip = true;
  147. // Bind button to the command.
  148. button.bind( 'isEnabled' ).to( unlinkCommand, 'isEnabled' );
  149. // Execute unlink command and hide panel, if open on button click.
  150. this.listenTo( button, 'execute', () => editor.execute( 'unlink' ) );
  151. return button;
  152. } );
  153. }
  154. /**
  155. * Attaches actions which defines when panel should be open or close.
  156. *
  157. * @private
  158. */
  159. _attachActions() {
  160. const viewDocument = this.editor.editing.view;
  161. const balloon = this.editor.ui.balloon;
  162. // Handle click on view document and show panel when selection is placed inside the link element.
  163. // Keep panel open until selection will be inside the same link element.
  164. this.listenTo( viewDocument, 'click', () => {
  165. const viewSelection = viewDocument.selection;
  166. const parentLink = getPositionParentLink( viewSelection.getFirstPosition() );
  167. // When collapsed selection is inside link element (link element is clicked).
  168. if ( viewSelection.isCollapsed && parentLink ) {
  169. // Then show panel but keep focus inside editor editable.
  170. this._showPanel();
  171. // Start listen to view document changes and close the panel when selection will be moved
  172. // out of the actual link element.
  173. this.listenTo( viewDocument, 'render', () => {
  174. const currentParentLink = getPositionParentLink( viewSelection.getFirstPosition() );
  175. if ( !viewSelection.isCollapsed || parentLink !== currentParentLink ) {
  176. this._hidePanel();
  177. } else {
  178. balloon.updatePosition();
  179. }
  180. } );
  181. }
  182. } );
  183. // Focus the form if link panel is visible and tab key has been pressed.
  184. this.editor.keystrokes.set( 'Tab', ( data, cancel ) => {
  185. if ( this._isVisible && !this.formView.focusTracker.isFocused ) {
  186. this.formView.focus();
  187. cancel();
  188. }
  189. } );
  190. // Close on `Esc` press when the editor is focused and link balloon is currently visible.
  191. this.editor.keystrokes.set( 'Esc', ( data, cancel ) => {
  192. if ( this._isVisible ) {
  193. this._hidePanel();
  194. cancel();
  195. }
  196. } );
  197. // Close on click outside of balloon panel element.
  198. clickOutsideHandler( {
  199. emitter: this.formView,
  200. activator: () => this._isInStack,
  201. contextElement: balloon.view.element,
  202. callback: () => this._hidePanel()
  203. } );
  204. }
  205. /**
  206. * Adds panel to {@link: core/editor/editorui~EditorUI#balloon}.
  207. *
  208. * @private
  209. * @param {Boolean} [focusInput=false] When `true` then link form will be focused on panel show.
  210. */
  211. _showPanel( focusInput ) {
  212. if ( this._isInStack ) {
  213. return;
  214. }
  215. this.editor.ui.balloon.add( {
  216. view: this.formView,
  217. position: this._getBalloonPositionData()
  218. } );
  219. if ( focusInput ) {
  220. this.formView.urlInputView.select();
  221. }
  222. }
  223. /**
  224. * Removes panel from {@link: core/editor/editorui~EditorUI#balloon}.
  225. *
  226. * @private
  227. * @param {Boolean} [focusEditable=false] When `true` then editable focus will be restored on panel hide.
  228. */
  229. _hidePanel( focusEditable ) {
  230. if ( !this._isInStack ) {
  231. return;
  232. }
  233. this.editor.ui.balloon.remove( this.formView );
  234. this.stopListening( this.editor.editing.view, 'render' );
  235. if ( focusEditable ) {
  236. this.editor.editing.view.focus();
  237. }
  238. }
  239. /**
  240. * Returns position configuration for the balloon panel. According to this data balloon will be attached
  241. * to the target element. If selection is collapsed and is placed inside link element, then panel
  242. * will be attached to whole link element, otherwise will be attached to the selection.
  243. *
  244. * @private
  245. * @returns {module:utils/dom/position~Options}
  246. */
  247. _getBalloonPositionData() {
  248. const viewDocument = this.editor.editing.view;
  249. const targetLink = getPositionParentLink( viewDocument.selection.getFirstPosition() );
  250. const target = targetLink ?
  251. // When selection is inside link element, then attach panel to this element.
  252. viewDocument.domConverter.getCorrespondingDomElement( targetLink )
  253. :
  254. // Otherwise attach panel to the selection.
  255. viewDocument.domConverter.viewRangeToDom( viewDocument.selection.getFirstRange() );
  256. return {
  257. target,
  258. limiter: viewDocument.domConverter.getCorrespondingDomElement( viewDocument.selection.editableElement )
  259. };
  260. }
  261. }
  262. // Try to find if one of the position parent ancestors is a LinkElement,
  263. // if yes return this element.
  264. //
  265. // @private
  266. // @param {engine.view.Position} position
  267. // @returns {module:link/linkelement~LinkElement|null}
  268. function getPositionParentLink( position ) {
  269. return position.parent.getAncestors().find( ( ancestor ) => ancestor instanceof LinkElement );
  270. }