8
0

link.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400
  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 Range from '@ckeditor/ckeditor5-engine/src/view/range';
  11. import LinkEngine from './linkengine';
  12. import LinkElement from './linkelement';
  13. import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextualballoon';
  14. import clickOutsideHandler from '@ckeditor/ckeditor5-ui/src/bindings/clickoutsidehandler';
  15. import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview';
  16. import LinkFormView from './ui/linkformview';
  17. import linkIcon from '../theme/icons/link.svg';
  18. import unlinkIcon from '../theme/icons/unlink.svg';
  19. import '../theme/theme.scss';
  20. const linkKeystroke = 'Ctrl+K';
  21. /**
  22. * The link plugin. It introduces the Link and Unlink buttons and the <kbd>Ctrl+K</kbd> keystroke.
  23. *
  24. * It uses the {@link module:link/linkengine~LinkEngine link engine plugin} and the
  25. * {@link module:ui/panel/balloon/contextualballoon~ContextualBalloon contextual balloon plugin}.
  26. *
  27. * @extends module:core/plugin~Plugin
  28. */
  29. export default class Link extends Plugin {
  30. /**
  31. * @inheritDoc
  32. */
  33. static get requires() {
  34. return [ LinkEngine, ContextualBalloon ];
  35. }
  36. /**
  37. * @inheritDoc
  38. */
  39. static get pluginName() {
  40. return 'Link';
  41. }
  42. /**
  43. * @inheritDoc
  44. */
  45. init() {
  46. this.editor.editing.view.addObserver( ClickObserver );
  47. /**
  48. * The form view displayed inside of the balloon.
  49. *
  50. * @member {module:link/ui/linkformview~LinkFormView}
  51. */
  52. this.formView = this._createForm();
  53. /**
  54. * The contextual balloon plugin instance.
  55. *
  56. * @private
  57. * @member {module:ui/panel/balloon/contextualballoon~ContextualBalloon}
  58. */
  59. this._balloon = this.editor.plugins.get( ContextualBalloon );
  60. // Create toolbar buttons.
  61. this._createToolbarLinkButton();
  62. this._createToolbarUnlinkButton();
  63. // Attach lifecycle actions to the the balloon.
  64. this._attachActions();
  65. }
  66. /**
  67. * Creates the {@link module:link/ui/linkformview~LinkFormView} instance.
  68. *
  69. * @private
  70. * @returns {module:link/ui/linkformview~LinkFormView} Link form instance.
  71. */
  72. _createForm() {
  73. const editor = this.editor;
  74. const formView = new LinkFormView( editor.locale );
  75. const linkCommand = editor.commands.get( 'link' );
  76. const unlinkCommand = editor.commands.get( 'unlink' );
  77. formView.urlInputView.bind( 'value' ).to( linkCommand, 'value' );
  78. // Form elements should be read-only when corresponding commands are disabled.
  79. formView.urlInputView.bind( 'isReadOnly' ).to( linkCommand, 'isEnabled', value => !value );
  80. formView.saveButtonView.bind( 'isEnabled' ).to( linkCommand );
  81. formView.unlinkButtonView.bind( 'isEnabled' ).to( unlinkCommand );
  82. // Execute link command after clicking on formView `Save` button.
  83. this.listenTo( formView, 'submit', () => {
  84. editor.execute( 'link', formView.urlInputView.inputView.element.value );
  85. this._hidePanel( true );
  86. } );
  87. // Execute unlink command after clicking on formView `Unlink` button.
  88. this.listenTo( formView, 'unlink', () => {
  89. editor.execute( 'unlink' );
  90. this._hidePanel( true );
  91. } );
  92. // Hide the panel after clicking on formView `Cancel` button.
  93. this.listenTo( formView, 'cancel', () => this._hidePanel( true ) );
  94. // Close the panel on esc key press when the form has focus.
  95. formView.keystrokes.set( 'Esc', ( data, cancel ) => {
  96. this._hidePanel( true );
  97. cancel();
  98. } );
  99. return formView;
  100. }
  101. /**
  102. * Creates a toolbar link button. Clicking this button will show
  103. * {@link #_balloon} attached to the selection.
  104. *
  105. * @private
  106. */
  107. _createToolbarLinkButton() {
  108. const editor = this.editor;
  109. const linkCommand = editor.commands.get( 'link' );
  110. const t = editor.t;
  111. // Handle `Ctrl+K` keystroke and show the panel.
  112. editor.keystrokes.set( linkKeystroke, () => {
  113. if ( linkCommand.isEnabled ) {
  114. this._showPanel( true );
  115. }
  116. } );
  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 = linkKeystroke;
  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 control whether the balloon panel containing the
  156. * {@link #formView} is visible or not.
  157. *
  158. * @private
  159. */
  160. _attachActions() {
  161. const viewDocument = this.editor.editing.view;
  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 parentLink = this._getSelectedLinkElement();
  166. if ( parentLink ) {
  167. // Then show panel but keep focus inside editor editable.
  168. this._showPanel();
  169. }
  170. } );
  171. // Focus the form if the balloon is visible and the Tab key has been pressed.
  172. this.editor.keystrokes.set( 'Tab', ( data, cancel ) => {
  173. if ( this._balloon.visibleView === this.formView && !this.formView.focusTracker.isFocused ) {
  174. this.formView.focus();
  175. cancel();
  176. }
  177. } );
  178. // Close the panel on the Esc key press when the editable has focus and the balloon is visible.
  179. this.editor.keystrokes.set( 'Esc', ( data, cancel ) => {
  180. if ( this._balloon.visibleView === this.formView ) {
  181. this._hidePanel();
  182. cancel();
  183. }
  184. } );
  185. // Close on click outside of balloon panel element.
  186. clickOutsideHandler( {
  187. emitter: this.formView,
  188. activator: () => this._balloon.hasView( this.formView ),
  189. contextElements: [ this._balloon.view.element ],
  190. callback: () => this._hidePanel()
  191. } );
  192. }
  193. /**
  194. * Adds the {@link #formView} to the {@link #_balloon}.
  195. * When view is already added then try to focus it `focusInput` parameter is set as true.
  196. *
  197. * @protected
  198. * @param {Boolean} [focusInput=false] When `true`, link form will be focused on panel show.
  199. */
  200. _showPanel( focusInput ) {
  201. const editor = this.editor;
  202. const command = editor.commands.get( 'link' );
  203. const editing = editor.editing;
  204. const showViewDocument = editing.view;
  205. const showIsCollapsed = showViewDocument.selection.isCollapsed;
  206. const showSelectedLink = this._getSelectedLinkElement();
  207. // https://github.com/ckeditor/ckeditor5-link/issues/53
  208. this.formView.unlinkButtonView.isVisible = !!showSelectedLink;
  209. // Make sure that each time the panel shows up, the URL field remains in sync with the value of
  210. // the command. If the user typed in the input, then canceled the balloon (`urlInputView#value` stays
  211. // unaltered) and re-opened it without changing the value of the link command (e.g. because they
  212. // clicked the same link), they would see the old value instead of the actual value of the command.
  213. // https://github.com/ckeditor/ckeditor5-link/issues/78
  214. // https://github.com/ckeditor/ckeditor5-link/issues/123
  215. this.formView.urlInputView.inputView.element.value = command.value || '';
  216. this.listenTo( showViewDocument, 'render', () => {
  217. const renderSelectedLink = this._getSelectedLinkElement();
  218. const renderIsCollapsed = showViewDocument.selection.isCollapsed;
  219. const hasSellectionExpanded = showIsCollapsed && !renderIsCollapsed;
  220. // Hide the panel if:
  221. // * the selection went out of the original link element
  222. // (e.g. paragraph containing the link was removed),
  223. // * the selection has expanded
  224. // upon the #render event.
  225. if ( hasSellectionExpanded || showSelectedLink !== renderSelectedLink ) {
  226. this._hidePanel( true );
  227. }
  228. // Update the position of the panel when:
  229. // * the selection remains in the original link element,
  230. // * there was no link element in the first place, i.e. creating a new link
  231. else {
  232. // If still in a link element, simply update the position of the balloon.
  233. if ( renderSelectedLink ) {
  234. this._balloon.updatePosition();
  235. }
  236. // If there was no link, upon #render, the balloon must be moved
  237. // to the new position in the editing view (a new native DOM range).
  238. else {
  239. this._balloon.updatePosition( this._getBalloonPositionData() );
  240. }
  241. }
  242. } );
  243. if ( this._balloon.hasView( this.formView ) ) {
  244. // Check if formView should be focused and focus it if is visible.
  245. if ( focusInput && this._balloon.visibleView === this.formView ) {
  246. this.formView.urlInputView.select();
  247. }
  248. } else {
  249. this._balloon.add( {
  250. view: this.formView,
  251. position: this._getBalloonPositionData()
  252. } );
  253. if ( focusInput ) {
  254. this.formView.urlInputView.select();
  255. }
  256. }
  257. }
  258. /**
  259. * Removes the {@link #formView} from the {@link #_balloon}.
  260. *
  261. * See {@link #_showPanel}.
  262. *
  263. * @protected
  264. * @param {Boolean} [focusEditable=false] When `true`, editable focus will be restored on panel hide.
  265. */
  266. _hidePanel( focusEditable ) {
  267. this.stopListening( this.editor.editing.view, 'render' );
  268. if ( !this._balloon.hasView( this.formView ) ) {
  269. return;
  270. }
  271. if ( focusEditable ) {
  272. this.editor.editing.view.focus();
  273. }
  274. this.stopListening( this.editor.editing.view, 'render' );
  275. this._balloon.remove( this.formView );
  276. }
  277. /**
  278. * Returns positioning options for the {@link #_balloon}. They control the way balloon is attached
  279. * to the target element or selection.
  280. *
  281. * If the selection is collapsed and inside a link element, then the panel will be attached to the
  282. * entire link element. Otherwise, it will be attached to the selection.
  283. *
  284. * @private
  285. * @returns {module:utils/dom/position~Options}
  286. */
  287. _getBalloonPositionData() {
  288. const viewDocument = this.editor.editing.view;
  289. const targetLink = this._getSelectedLinkElement();
  290. const target = targetLink ?
  291. // When selection is inside link element, then attach panel to this element.
  292. viewDocument.domConverter.mapViewToDom( targetLink ) :
  293. // Otherwise attach panel to the selection.
  294. viewDocument.domConverter.viewRangeToDom( viewDocument.selection.getFirstRange() );
  295. return {
  296. target,
  297. limiter: viewDocument.domConverter.mapViewToDom( viewDocument.selection.editableElement )
  298. };
  299. }
  300. /**
  301. * Returns the {@link module:link/linkelement~LinkElement} under
  302. * {@link module:engine/view/document~Document editing view's} selection or `null`
  303. * if there's none.
  304. *
  305. * **Note**: For non–collapsed selection the `LinkElement` is only returned when **fully**
  306. * selected and the **only** element within the selection boundaries.
  307. *
  308. * @private
  309. * @returns {module:link/linkelement~LinkElement|null}
  310. */
  311. _getSelectedLinkElement() {
  312. const selection = this.editor.editing.view.selection;
  313. if ( selection.isCollapsed ) {
  314. return findLinkElementAncestor( selection.getFirstPosition() );
  315. } else {
  316. // The range for fully selected link is usually anchored in adjacent text nodes.
  317. // Trim it to get closer to the actual LinkElement.
  318. const range = selection.getFirstRange().getTrimmed();
  319. const startLink = findLinkElementAncestor( range.start );
  320. const endLink = findLinkElementAncestor( range.end );
  321. if ( !startLink || startLink != endLink ) {
  322. return null;
  323. }
  324. // Check if the LinkElement is fully selected.
  325. if ( Range.createIn( startLink ).getTrimmed().isEqual( range ) ) {
  326. return startLink;
  327. } else {
  328. return null;
  329. }
  330. }
  331. }
  332. }
  333. // Returns a `LinkElement` if there's one among the ancestors of the provided `Position`.
  334. //
  335. // @private
  336. // @param {module:engine/view/position~Position} View position to analyze.
  337. // @returns {module:link/linkelement~LinkElement|null} LinkElement at the position or null.
  338. function findLinkElementAncestor( position ) {
  339. return position.getAncestors().find( ancestor => ancestor instanceof LinkElement );
  340. }