linkediting.js 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. /**
  2. * @license Copyright (c) 2003-2019, 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/linkediting
  7. */
  8. import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
  9. import LinkCommand from './linkcommand';
  10. import UnlinkCommand from './unlinkcommand';
  11. import { createLinkElement, ensureSafeUrl } from './utils';
  12. import AutomaticDecorators from './utils/automaticdecorators';
  13. import ManualDecorator from './utils/manualdecorator';
  14. import bindTwoStepCaretToAttribute from '@ckeditor/ckeditor5-engine/src/utils/bindtwostepcarettoattribute';
  15. import findLinkRange from './findlinkrange';
  16. import '../theme/link.css';
  17. const HIGHLIGHT_CLASS = 'ck-link_selected';
  18. const DECORATOR_AUTOMATIC = 'automatic';
  19. const DECORATOR_MANUAL = 'manual';
  20. const EXTERNAL_LINKS_REGEXP = /^(https?:)?\/\//;
  21. /**
  22. * The link engine feature.
  23. *
  24. * It introduces the `linkHref="url"` attribute in the model which renders to the view as a `<a href="url">` element
  25. * as well as `'link'` and `'unlink'` commands.
  26. *
  27. * @extends module:core/plugin~Plugin
  28. */
  29. export default class LinkEditing extends Plugin {
  30. /**
  31. * @inheritDoc
  32. */
  33. constructor( editor ) {
  34. super( editor );
  35. editor.config.define( 'link', {
  36. targetDecorator: false
  37. } );
  38. }
  39. /**
  40. * @inheritDoc
  41. */
  42. init() {
  43. const editor = this.editor;
  44. // Allow link attribute on all inline nodes.
  45. editor.model.schema.extend( '$text', { allowAttributes: 'linkHref' } );
  46. editor.conversion.for( 'dataDowncast' )
  47. .attributeToElement( { model: 'linkHref', view: createLinkElement } );
  48. editor.conversion.for( 'editingDowncast' )
  49. .attributeToElement( { model: 'linkHref', view: ( href, writer ) => {
  50. return createLinkElement( ensureSafeUrl( href ), writer );
  51. } } );
  52. editor.conversion.for( 'upcast' )
  53. .elementToAttribute( {
  54. view: {
  55. name: 'a',
  56. attributes: {
  57. href: true
  58. }
  59. },
  60. model: {
  61. key: 'linkHref',
  62. value: viewElement => viewElement.getAttribute( 'href' )
  63. }
  64. } );
  65. // Create linking commands.
  66. editor.commands.add( 'link', new LinkCommand( editor ) );
  67. editor.commands.add( 'unlink', new UnlinkCommand( editor ) );
  68. const linkDecorators = editor.config.get( 'link.decorators' ) || [];
  69. this._enableAutomaticDecorators( linkDecorators.filter( item => item.mode === DECORATOR_AUTOMATIC ) );
  70. this._enableManualDecorators( linkDecorators.filter( item => item.mode === DECORATOR_MANUAL ) );
  71. // Enable two-step caret movement for `linkHref` attribute.
  72. bindTwoStepCaretToAttribute( editor.editing.view, editor.model, this, 'linkHref' );
  73. // Setup highlight over selected link.
  74. this._setupLinkHighlight();
  75. }
  76. /**
  77. * Method process {@link module:link/link~LinkDecoratorAutomaticOption} by creating instance of
  78. * {@link module:link/utils~AutomaticDecorators}. If there are available automatic decorators, then
  79. * there is registered {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher} to handle
  80. * those configurations.
  81. *
  82. * Please notice, that automatic decorator will be also added, when {@link module:link/link~LinkConfig#targetDecorator}
  83. * will be set to `true`.
  84. *
  85. * @private
  86. * @param {Array.<module:link/link~LinkDecoratorAutomaticOption>} automaticDecoratorDefinitions
  87. */
  88. _enableAutomaticDecorators( automaticDecoratorDefinitions ) {
  89. const editor = this.editor;
  90. const automaticDecorators = new AutomaticDecorators();
  91. // Adds default decorator for external links.
  92. if ( editor.config.get( 'link.targetDecorator' ) ) {
  93. automaticDecorators.add( {
  94. mode: DECORATOR_AUTOMATIC,
  95. callback: url => {
  96. return EXTERNAL_LINKS_REGEXP.test( url );
  97. },
  98. attributes: {
  99. target: '_blank',
  100. rel: 'noopener noreferrer'
  101. }
  102. } );
  103. }
  104. automaticDecorators.add( automaticDecoratorDefinitions );
  105. if ( automaticDecorators.length ) {
  106. editor.conversion.for( 'downcast' ).add( automaticDecorators.getDispatcher() );
  107. }
  108. }
  109. /**
  110. * Method process {@link module:link/link~LinkDecoratorManualOption} by transformation those configuration options into
  111. * {@link module:link/utils~ManualDecorator}. Manual decorators are added to
  112. * {@link module:link/linkcommand~LinkCommand#manualDecorators} collections, which might be considered as a model
  113. * for manual decorators state. It also provides proper
  114. * {@link module:engine/conversion/downcasthelpers~DowncastHelpers#attributeToElement attributeToElement} converter for each
  115. * manual decorator and extends {@link module:engine/model/schema~Schema model's schema} with adequate model attributes.
  116. *
  117. * @private
  118. * @param {Array.<module:link/link~LinkDecoratorManualOption>} manualDecoratorDefinitions
  119. */
  120. _enableManualDecorators( manualDecoratorDefinitions ) {
  121. if ( !manualDecoratorDefinitions.length ) {
  122. return;
  123. }
  124. const editor = this.editor;
  125. const command = editor.commands.get( 'link' );
  126. const manualDecorators = command.manualDecorators;
  127. manualDecoratorDefinitions.forEach( ( decorator, index ) => {
  128. const decoratorName = `linkManualDecorator${ index }`;
  129. editor.model.schema.extend( '$text', { allowAttributes: decoratorName } );
  130. // Keeps reference to manual decorator to decode its name to attributes during downcast.
  131. manualDecorators.add( new ManualDecorator( Object.assign( { id: decoratorName }, decorator ) ) );
  132. editor.conversion.for( 'downcast' ).attributeToElement( {
  133. model: decoratorName,
  134. view: ( manualDecoratorName, writer ) => {
  135. if ( manualDecoratorName ) {
  136. const element = writer.createAttributeElement(
  137. 'a',
  138. manualDecorators.get( decoratorName ).attributes,
  139. {
  140. priority: 5
  141. }
  142. );
  143. writer.setCustomProperty( 'link', true, element );
  144. return element;
  145. }
  146. } } );
  147. } );
  148. }
  149. /**
  150. * Adds a visual highlight style to a link in which the selection is anchored.
  151. * Together with two-step caret movement, they indicate that the user is typing inside the link.
  152. *
  153. * Highlight is turned on by adding `.ck-link_selected` class to the link in the view:
  154. *
  155. * * the class is removed before conversion has started, as callbacks added with `'highest'` priority
  156. * to {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher} events,
  157. * * the class is added in the view post fixer, after other changes in the model tree were converted to the view.
  158. *
  159. * This way, adding and removing highlight does not interfere with conversion.
  160. *
  161. * @private
  162. */
  163. _setupLinkHighlight() {
  164. const editor = this.editor;
  165. const view = editor.editing.view;
  166. const highlightedLinks = new Set();
  167. // Adding the class.
  168. view.document.registerPostFixer( writer => {
  169. const selection = editor.model.document.selection;
  170. if ( selection.hasAttribute( 'linkHref' ) ) {
  171. const modelRange = findLinkRange( selection.getFirstPosition(), selection.getAttribute( 'linkHref' ), editor.model );
  172. const viewRange = editor.editing.mapper.toViewRange( modelRange );
  173. // There might be multiple `a` elements in the `viewRange`, for example, when the `a` element is
  174. // broken by a UIElement.
  175. for ( const item of viewRange.getItems() ) {
  176. if ( item.is( 'a' ) ) {
  177. writer.addClass( HIGHLIGHT_CLASS, item );
  178. highlightedLinks.add( item );
  179. }
  180. }
  181. }
  182. } );
  183. // Removing the class.
  184. editor.conversion.for( 'editingDowncast' ).add( dispatcher => {
  185. // Make sure the highlight is removed on every possible event, before conversion is started.
  186. dispatcher.on( 'insert', removeHighlight, { priority: 'highest' } );
  187. dispatcher.on( 'remove', removeHighlight, { priority: 'highest' } );
  188. dispatcher.on( 'attribute', removeHighlight, { priority: 'highest' } );
  189. dispatcher.on( 'selection', removeHighlight, { priority: 'highest' } );
  190. function removeHighlight() {
  191. view.change( writer => {
  192. for ( const item of highlightedLinks.values() ) {
  193. writer.removeClass( HIGHLIGHT_CLASS, item );
  194. highlightedLinks.delete( item );
  195. }
  196. } );
  197. }
  198. } );
  199. }
  200. }