autolink.js 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  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/autolink
  7. */
  8. import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
  9. import TextWatcher from '@ckeditor/ckeditor5-typing/src/textwatcher';
  10. import getLastTextLine from '@ckeditor/ckeditor5-typing/src/utils/getlasttextline';
  11. const MIN_LINK_LENGTH_WITH_SPACE_AT_END = 4; // Ie: "t.co " (length 5).
  12. // This was tweak from https://gist.github.com/dperini/729294.
  13. const URL_REG_EXP = new RegExp(
  14. // Group 1: Line start or after a space.
  15. '(^|\\s)' +
  16. // Group 2: Detected URL (or e-mail).
  17. '(' +
  18. // Protocol identifier or short syntax "//"
  19. // a. Full form http://user@foo.bar.baz:8080/foo/bar.html#baz?foo=bar
  20. '(' +
  21. '(?:(?:(?:https?|ftp):)?\\/\\/)' +
  22. // BasicAuth using user:pass (optional)
  23. '(?:\\S+(?::\\S*)?@)?' +
  24. '(?:' +
  25. // Host & domain names.
  26. '(?![-_])(?:[-\\w\\u00a1-\\uffff]{0,63}[^-_]\\.)+' +
  27. // TLD identifier name.
  28. '(?:[a-z\\u00a1-\\uffff]{2,})' +
  29. ')' +
  30. // port number (optional)
  31. '(?::\\d{2,5})?' +
  32. // resource path (optional)
  33. '(?:[/?#]\\S*)?' +
  34. ')' +
  35. '|' +
  36. // b. Short form (either www.example.com or example@example.com)
  37. '(' +
  38. '(www.|(\\S+@))' +
  39. // Host & domain names.
  40. '((?![-_])(?:[-\\w\\u00a1-\\uffff]{0,63}[^-_]\\.))+' +
  41. // TLD identifier name.
  42. '(?:[a-z\\u00a1-\\uffff]{2,})' +
  43. ')' +
  44. ')$', 'i' );
  45. const URL_GROUP_IN_MATCH = 2;
  46. // Simplified email test - should be run over previously found URL.
  47. const EMAIL_REG_EXP = /^[\S]+@((?![-_])(?:[-\w\u00a1-\uffff]{0,63}[^-_]\.))+(?:[a-z\u00a1-\uffff]{2,})$/i;
  48. /**
  49. * The auto link plugin.
  50. *
  51. * @extends module:core/plugin~Plugin
  52. */
  53. export default class AutoLink extends Plugin {
  54. /**
  55. * @inheritDoc
  56. */
  57. static get pluginName() {
  58. return 'AutoLink';
  59. }
  60. /**
  61. * @inheritDoc
  62. */
  63. init() {
  64. const editor = this.editor;
  65. const selection = editor.model.document.selection;
  66. selection.on( 'change:range', () => {
  67. // Disable plugin when selection is inside a code block.
  68. this.isEnabled = !selection.anchor.parent.is( 'codeBlock' );
  69. } );
  70. this._enableTypingHandling();
  71. }
  72. /**
  73. * @inheritDoc
  74. */
  75. afterInit() {
  76. this._enableEnterHandling();
  77. this._enableShiftEnterHandling();
  78. }
  79. /**
  80. * Enables auto-link on typing.
  81. *
  82. * @private
  83. */
  84. _enableTypingHandling() {
  85. const editor = this.editor;
  86. const watcher = new TextWatcher( editor.model, text => {
  87. // 1. Detect "space" after a text with a potential link.
  88. if ( !isSingleSpaceAtTheEnd( text ) ) {
  89. return;
  90. }
  91. // 2. Check text before last typed "space".
  92. const url = getUrlAtTextEnd( text.substr( 0, text.length - 1 ) );
  93. if ( url ) {
  94. return { url };
  95. }
  96. } );
  97. const input = editor.plugins.get( 'Input' );
  98. watcher.on( 'matched:data', ( evt, data ) => {
  99. const { batch, range, url } = data;
  100. if ( !input.isInput( batch ) ) {
  101. return;
  102. }
  103. const linkRange = editor.model.createRange(
  104. range.end.getShiftedBy( -( 1 + url.length ) ),
  105. range.end.getShiftedBy( -1 )
  106. );
  107. this._applyAutoLink( url, linkRange );
  108. } );
  109. watcher.bind( 'isEnabled' ).to( this );
  110. }
  111. /**
  112. * Enables auto-link on <kbd>enter</kbd> key.
  113. *
  114. * @private
  115. */
  116. _enableEnterHandling() {
  117. const editor = this.editor;
  118. const model = editor.model;
  119. const enterCommand = editor.commands.get( 'enter' );
  120. enterCommand.on( 'execute', () => {
  121. const position = model.document.selection.getFirstPosition();
  122. const rangeToCheck = model.createRange(
  123. model.createPositionAt( position.parent.previousSibling, 0 ),
  124. model.createPositionAt( position.parent.previousSibling, 'end' )
  125. );
  126. this._checkAndApplyAutoLinkOnRange( rangeToCheck );
  127. } );
  128. }
  129. /**
  130. * Enables auto-link on <kbd>shift</kbd>+<kbd>enter</kbd> key.
  131. *
  132. * @private
  133. */
  134. _enableShiftEnterHandling() {
  135. const editor = this.editor;
  136. const model = editor.model;
  137. const shiftEnterCommand = editor.commands.get( 'shiftEnter' );
  138. shiftEnterCommand.on( 'execute', () => {
  139. const position = model.document.selection.getFirstPosition();
  140. const rangeToCheck = model.createRange(
  141. model.createPositionAt( position.parent, 0 ),
  142. position.getShiftedBy( -1 )
  143. );
  144. this._checkAndApplyAutoLinkOnRange( rangeToCheck );
  145. } );
  146. }
  147. /**
  148. * Checks passed range if it contains a linkable text.
  149. *
  150. * @param {module:engine/model/range~Range} rangeToCheck
  151. * @private
  152. */
  153. _checkAndApplyAutoLinkOnRange( rangeToCheck ) {
  154. const model = this.editor.model;
  155. const { text, range } = getLastTextLine( rangeToCheck, model );
  156. const url = getUrlAtTextEnd( text );
  157. if ( url ) {
  158. const linkRange = model.createRange(
  159. range.end.getShiftedBy( -url.length ),
  160. range.end
  161. );
  162. this._applyAutoLink( url, linkRange );
  163. }
  164. }
  165. /**
  166. * Applies link on a given range.
  167. *
  168. * @param {String} url URL to link.
  169. * @param {module:engine/model/range~Range} range Text range to apply link attribute.
  170. * @private
  171. */
  172. _applyAutoLink( url, range ) {
  173. // Enqueue change to make undo step.
  174. this.editor.model.enqueueChange( writer => {
  175. const linkHrefValue = isEmail( url ) ? `mailto://${ url }` : url;
  176. writer.setAttribute( 'linkHref', linkHrefValue, range );
  177. } );
  178. }
  179. }
  180. // Check if text should be evaluated by the plugin in order to reduce number of RegExp checks on whole text.
  181. function isSingleSpaceAtTheEnd( text ) {
  182. return text.length > MIN_LINK_LENGTH_WITH_SPACE_AT_END && text[ text.length - 1 ] === ' ' && text[ text.length - 2 ] !== ' ';
  183. }
  184. function getUrlAtTextEnd( text ) {
  185. const match = URL_REG_EXP.exec( text );
  186. return match ? match[ URL_GROUP_IN_MATCH ] : null;
  187. }
  188. function isEmail( linkHref ) {
  189. return EMAIL_REG_EXP.exec( linkHref );
  190. }