8
0

linkediting.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553
  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/linkediting
  7. */
  8. import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
  9. import MouseObserver from '@ckeditor/ckeditor5-engine/src/view/observer/mouseobserver';
  10. import TwoStepCaretMovement from '@ckeditor/ckeditor5-typing/src/twostepcaretmovement';
  11. import Input from '@ckeditor/ckeditor5-typing/src/input';
  12. import Clipboard from '@ckeditor/ckeditor5-clipboard/src/clipboard';
  13. import LinkCommand from './linkcommand';
  14. import UnlinkCommand from './unlinkcommand';
  15. import ManualDecorator from './utils/manualdecorator';
  16. import findLinkRange from './findlinkrange';
  17. import { createLinkElement, ensureSafeUrl, getLocalizedDecorators, normalizeDecorators } from './utils';
  18. import '../theme/link.css';
  19. const HIGHLIGHT_CLASS = 'ck-link_selected';
  20. const DECORATOR_AUTOMATIC = 'automatic';
  21. const DECORATOR_MANUAL = 'manual';
  22. const EXTERNAL_LINKS_REGEXP = /^(https?:)?\/\//;
  23. /**
  24. * The link engine feature.
  25. *
  26. * It introduces the `linkHref="url"` attribute in the model which renders to the view as a `<a href="url">` element
  27. * as well as `'link'` and `'unlink'` commands.
  28. *
  29. * @extends module:core/plugin~Plugin
  30. */
  31. export default class LinkEditing extends Plugin {
  32. /**
  33. * @inheritDoc
  34. */
  35. static get pluginName() {
  36. return 'LinkEditing';
  37. }
  38. /**
  39. * @inheritDoc
  40. */
  41. static get requires() {
  42. // Clipboard is required for handling cut and paste events while typing over the link.
  43. return [ TwoStepCaretMovement, Input, Clipboard ];
  44. }
  45. /**
  46. * @inheritDoc
  47. */
  48. constructor( editor ) {
  49. super( editor );
  50. editor.config.define( 'link', {
  51. addTargetToExternalLinks: false
  52. } );
  53. }
  54. /**
  55. * @inheritDoc
  56. */
  57. init() {
  58. const editor = this.editor;
  59. // Allow link attribute on all inline nodes.
  60. editor.model.schema.extend( '$text', { allowAttributes: 'linkHref' } );
  61. editor.conversion.for( 'dataDowncast' )
  62. .attributeToElement( { model: 'linkHref', view: createLinkElement } );
  63. editor.conversion.for( 'editingDowncast' )
  64. .attributeToElement( { model: 'linkHref', view: ( href, writer ) => {
  65. return createLinkElement( ensureSafeUrl( href ), writer );
  66. } } );
  67. editor.conversion.for( 'upcast' )
  68. .elementToAttribute( {
  69. view: {
  70. name: 'a',
  71. attributes: {
  72. href: true
  73. }
  74. },
  75. model: {
  76. key: 'linkHref',
  77. value: viewElement => viewElement.getAttribute( 'href' )
  78. }
  79. } );
  80. // Create linking commands.
  81. editor.commands.add( 'link', new LinkCommand( editor ) );
  82. editor.commands.add( 'unlink', new UnlinkCommand( editor ) );
  83. const linkDecorators = getLocalizedDecorators( editor.t, normalizeDecorators( editor.config.get( 'link.decorators' ) ) );
  84. this._enableAutomaticDecorators( linkDecorators.filter( item => item.mode === DECORATOR_AUTOMATIC ) );
  85. this._enableManualDecorators( linkDecorators.filter( item => item.mode === DECORATOR_MANUAL ) );
  86. // Enable two-step caret movement for `linkHref` attribute.
  87. const twoStepCaretMovementPlugin = editor.plugins.get( TwoStepCaretMovement );
  88. twoStepCaretMovementPlugin.registerAttribute( 'linkHref' );
  89. // Setup highlight over selected link.
  90. this._setupLinkHighlight();
  91. // Change the attributes of the selection in certain situations after the link was inserted into the document.
  92. this._enableInsertContentSelectionAttributesFixer();
  93. // Handle a click at the beginning/end of a link element.
  94. this._enableClickingAfterLink();
  95. // Handle typing over the link.
  96. this._enableTypingOverLink();
  97. }
  98. /**
  99. * Processes an array of configured {@link module:link/link~LinkDecoratorAutomaticDefinition automatic decorators}
  100. * and registers a {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher downcast dispatcher}
  101. * for each one of them. Downcast dispatchers are obtained using the
  102. * {@link module:link/utils~AutomaticDecorators#getDispatcher} method.
  103. *
  104. * **Note**: This method also activates the automatic external link decorator if enabled with
  105. * {@link module:link/link~LinkConfig#addTargetToExternalLinks `config.link.addTargetToExternalLinks`}.
  106. *
  107. * @private
  108. * @param {Array.<module:link/link~LinkDecoratorAutomaticDefinition>} automaticDecoratorDefinitions
  109. */
  110. _enableAutomaticDecorators( automaticDecoratorDefinitions ) {
  111. const editor = this.editor;
  112. // Store automatic decorators in the command instance as we do the same with manual decorators.
  113. // Thanks to that, `LinkImageEditing` plugin can re-use the same definitions.
  114. const command = editor.commands.get( 'link' );
  115. const automaticDecorators = command.automaticDecorators;
  116. // Adds a default decorator for external links.
  117. if ( editor.config.get( 'link.addTargetToExternalLinks' ) ) {
  118. automaticDecorators.add( {
  119. id: 'linkIsExternal',
  120. mode: DECORATOR_AUTOMATIC,
  121. callback: url => EXTERNAL_LINKS_REGEXP.test( url ),
  122. attributes: {
  123. target: '_blank',
  124. rel: 'noopener noreferrer'
  125. }
  126. } );
  127. }
  128. automaticDecorators.add( automaticDecoratorDefinitions );
  129. if ( automaticDecorators.length ) {
  130. editor.conversion.for( 'downcast' ).add( automaticDecorators.getDispatcher() );
  131. }
  132. }
  133. /**
  134. * Processes an array of configured {@link module:link/link~LinkDecoratorManualDefinition manual decorators},
  135. * transforms them into {@link module:link/utils~ManualDecorator} instances and stores them in the
  136. * {@link module:link/linkcommand~LinkCommand#manualDecorators} collection (a model for manual decorators state).
  137. *
  138. * Also registers an {@link module:engine/conversion/downcasthelpers~DowncastHelpers#attributeToElement attribute-to-element}
  139. * converter for each manual decorator and extends the {@link module:engine/model/schema~Schema model's schema}
  140. * with adequate model attributes.
  141. *
  142. * @private
  143. * @param {Array.<module:link/link~LinkDecoratorManualDefinition>} manualDecoratorDefinitions
  144. */
  145. _enableManualDecorators( manualDecoratorDefinitions ) {
  146. if ( !manualDecoratorDefinitions.length ) {
  147. return;
  148. }
  149. const editor = this.editor;
  150. const command = editor.commands.get( 'link' );
  151. const manualDecorators = command.manualDecorators;
  152. manualDecoratorDefinitions.forEach( decorator => {
  153. editor.model.schema.extend( '$text', { allowAttributes: decorator.id } );
  154. // Keeps reference to manual decorator to decode its name to attributes during downcast.
  155. manualDecorators.add( new ManualDecorator( decorator ) );
  156. editor.conversion.for( 'downcast' ).attributeToElement( {
  157. model: decorator.id,
  158. view: ( manualDecoratorName, writer ) => {
  159. if ( manualDecoratorName ) {
  160. const attributes = manualDecorators.get( decorator.id ).attributes;
  161. const element = writer.createAttributeElement( 'a', attributes, { priority: 5 } );
  162. writer.setCustomProperty( 'link', true, element );
  163. return element;
  164. }
  165. } } );
  166. editor.conversion.for( 'upcast' ).elementToAttribute( {
  167. view: {
  168. name: 'a',
  169. attributes: manualDecorators.get( decorator.id ).attributes
  170. },
  171. model: {
  172. key: decorator.id
  173. }
  174. } );
  175. } );
  176. }
  177. /**
  178. * Adds a visual highlight style to a link in which the selection is anchored.
  179. * Together with two-step caret movement, they indicate that the user is typing inside the link.
  180. *
  181. * Highlight is turned on by adding the `.ck-link_selected` class to the link in the view:
  182. *
  183. * * The class is removed before the conversion has started, as callbacks added with the `'highest'` priority
  184. * to {@link module:engine/conversion/downcastdispatcher~DowncastDispatcher} events.
  185. * * The class is added in the view post fixer, after other changes in the model tree were converted to the view.
  186. *
  187. * This way, adding and removing the highlight does not interfere with conversion.
  188. *
  189. * @private
  190. */
  191. _setupLinkHighlight() {
  192. const editor = this.editor;
  193. const view = editor.editing.view;
  194. const highlightedLinks = new Set();
  195. // Adding the class.
  196. view.document.registerPostFixer( writer => {
  197. const selection = editor.model.document.selection;
  198. let changed = false;
  199. if ( selection.hasAttribute( 'linkHref' ) ) {
  200. const modelRange = findLinkRange( selection.getFirstPosition(), selection.getAttribute( 'linkHref' ), editor.model );
  201. const viewRange = editor.editing.mapper.toViewRange( modelRange );
  202. // There might be multiple `a` elements in the `viewRange`, for example, when the `a` element is
  203. // broken by a UIElement.
  204. for ( const item of viewRange.getItems() ) {
  205. if ( item.is( 'a' ) && !item.hasClass( HIGHLIGHT_CLASS ) ) {
  206. writer.addClass( HIGHLIGHT_CLASS, item );
  207. highlightedLinks.add( item );
  208. changed = true;
  209. }
  210. }
  211. }
  212. return changed;
  213. } );
  214. // Removing the class.
  215. editor.conversion.for( 'editingDowncast' ).add( dispatcher => {
  216. // Make sure the highlight is removed on every possible event, before conversion is started.
  217. dispatcher.on( 'insert', removeHighlight, { priority: 'highest' } );
  218. dispatcher.on( 'remove', removeHighlight, { priority: 'highest' } );
  219. dispatcher.on( 'attribute', removeHighlight, { priority: 'highest' } );
  220. dispatcher.on( 'selection', removeHighlight, { priority: 'highest' } );
  221. function removeHighlight() {
  222. view.change( writer => {
  223. for ( const item of highlightedLinks.values() ) {
  224. writer.removeClass( HIGHLIGHT_CLASS, item );
  225. highlightedLinks.delete( item );
  226. }
  227. } );
  228. }
  229. } );
  230. }
  231. /**
  232. * Starts listening to {@link module:engine/model/model~Model#event:insertContent} and corrects the model
  233. * selection attributes if the selection is at the end of a link after inserting the content.
  234. *
  235. * The purpose of this action is to improve the overall UX because the user is no longer "trapped" by the
  236. * `linkHref` attribute of the selection and they can type a "clean" (`linkHref`–less) text right away.
  237. *
  238. * See https://github.com/ckeditor/ckeditor5/issues/6053.
  239. *
  240. * @private
  241. */
  242. _enableInsertContentSelectionAttributesFixer() {
  243. const editor = this.editor;
  244. const model = editor.model;
  245. const selection = model.document.selection;
  246. model.on( 'insertContent', () => {
  247. const nodeBefore = selection.anchor.nodeBefore;
  248. const nodeAfter = selection.anchor.nodeAfter;
  249. // NOTE: ↰ and ↱ represent the gravity of the selection.
  250. // The only truly valid case is:
  251. //
  252. // ↰
  253. // ...<$text linkHref="foo">INSERTED[]</$text>
  254. //
  255. // If the selection is not "trapped" by the `linkHref` attribute after inserting, there's nothing
  256. // to fix there.
  257. if ( !selection.hasAttribute( 'linkHref' ) ) {
  258. return;
  259. }
  260. // Filter out the following case where a link with the same href (e.g. <a href="foo">INSERTED</a>) is inserted
  261. // in the middle of an existing link:
  262. //
  263. // Before insertion:
  264. // ↰
  265. // <$text linkHref="foo">l[]ink</$text>
  266. //
  267. // Expected after insertion:
  268. // ↰
  269. // <$text linkHref="foo">lINSERTED[]ink</$text>
  270. //
  271. if ( !nodeBefore ) {
  272. return;
  273. }
  274. // Filter out the following case where the selection has the "linkHref" attribute because the
  275. // gravity is overridden and some text with another attribute (e.g. <b>INSERTED</b>) is inserted:
  276. //
  277. // Before insertion:
  278. //
  279. // ↱
  280. // <$text linkHref="foo">[]link</$text>
  281. //
  282. // Expected after insertion:
  283. //
  284. // ↱
  285. // <$text bold="true">INSERTED</$text><$text linkHref="foo">[]link</$text>
  286. //
  287. if ( !nodeBefore.hasAttribute( 'linkHref' ) ) {
  288. return;
  289. }
  290. // Filter out the following case where a link is a inserted in the middle (or before) another link
  291. // (different URLs, so they will not merge). In this (let's say weird) case, we can leave the selection
  292. // attributes as they are because the user will end up writing in one link or another anyway.
  293. //
  294. // Before insertion:
  295. //
  296. // ↰
  297. // <$text linkHref="foo">l[]ink</$text>
  298. //
  299. // Expected after insertion:
  300. //
  301. // ↰
  302. // <$text linkHref="foo">l</$text><$text linkHref="bar">INSERTED[]</$text><$text linkHref="foo">ink</$text>
  303. //
  304. if ( nodeAfter && nodeAfter.hasAttribute( 'linkHref' ) ) {
  305. return;
  306. }
  307. // Make the selection free of link-related model attributes.
  308. // All link-related model attributes start with "link". That includes not only "linkHref"
  309. // but also all decorator attributes (they have dynamic names).
  310. model.change( writer => {
  311. [ ...model.document.selection.getAttributeKeys() ]
  312. .filter( name => name.startsWith( 'link' ) )
  313. .forEach( name => writer.removeSelectionAttribute( name ) );
  314. } );
  315. }, { priority: 'low' } );
  316. }
  317. /**
  318. * Starts listening to {@link module:engine/view/document~Document#event:mousedown} and
  319. * {@link module:engine/view/document~Document#event:selectionChange} and puts the selection before/after a link node
  320. * if clicked at the beginning/ending of the link.
  321. *
  322. * The purpose of this action is to allow typing around the link node directly after a click.
  323. *
  324. * See https://github.com/ckeditor/ckeditor5/issues/1016.
  325. *
  326. * @private
  327. */
  328. _enableClickingAfterLink() {
  329. const editor = this.editor;
  330. editor.editing.view.addObserver( MouseObserver );
  331. let clicked = false;
  332. // Detect the click.
  333. this.listenTo( editor.editing.view.document, 'mousedown', () => {
  334. clicked = true;
  335. } );
  336. // When the selection has changed...
  337. this.listenTo( editor.editing.view.document, 'selectionChange', () => {
  338. if ( !clicked ) {
  339. return;
  340. }
  341. // ...and it was caused by the click...
  342. clicked = false;
  343. const selection = editor.model.document.selection;
  344. // ...and no text is selected...
  345. if ( !selection.isCollapsed ) {
  346. return;
  347. }
  348. // ...and clicked text is the link...
  349. if ( !selection.hasAttribute( 'linkHref' ) ) {
  350. return;
  351. }
  352. const position = selection.getFirstPosition();
  353. const linkRange = findLinkRange( position, selection.getAttribute( 'linkHref' ), editor.model );
  354. // ...check whether clicked start/end boundary of the link.
  355. // If so, remove the `linkHref` attribute.
  356. if ( position.isTouching( linkRange.start ) || position.isTouching( linkRange.end ) ) {
  357. editor.model.change( writer => {
  358. writer.removeSelectionAttribute( 'linkHref' );
  359. for ( const manualDecorator of editor.commands.get( 'link' ).manualDecorators ) {
  360. writer.removeSelectionAttribute( manualDecorator.id );
  361. }
  362. } );
  363. }
  364. } );
  365. }
  366. /**
  367. * Starts listening to {@link module:engine/model/model~Model#deleteContent} and {@link module:engine/model/model~Model#insertContent}
  368. * and checks whether typing over the link. If so, attributes of removed text are preserved and applied to the inserted text.
  369. *
  370. * The purpose of this action is to allow modifying a text without loosing the `linkHref` attribute (and other).
  371. *
  372. * See https://github.com/ckeditor/ckeditor5/issues/4762.
  373. *
  374. * @private
  375. */
  376. _enableTypingOverLink() {
  377. const editor = this.editor;
  378. const view = editor.editing.view;
  379. // Selection attributes when started typing over the link.
  380. let selectionAttributes;
  381. // Whether pressed `Backspace` or `Delete`. If so, attributes should not be preserved.
  382. let deletedContent;
  383. // Detect pressing `Backspace` / `Delete`.
  384. this.listenTo( view.document, 'delete', () => {
  385. deletedContent = true;
  386. }, { priority: 'high' } );
  387. // Listening to `model#deleteContent` allows detecting whether selected content was a link.
  388. // If so, before removing the element, we will copy its attributes.
  389. this.listenTo( editor.model, 'deleteContent', () => {
  390. const selection = editor.model.document.selection;
  391. // Copy attributes only if anything is selected.
  392. if ( selection.isCollapsed ) {
  393. return;
  394. }
  395. // When the content was deleted, do not preserve attributes.
  396. if ( deletedContent ) {
  397. deletedContent = false;
  398. return;
  399. }
  400. // Enabled only when typing.
  401. if ( !isTyping( editor ) ) {
  402. return;
  403. }
  404. if ( shouldCopyAttributes( editor.model ) ) {
  405. selectionAttributes = selection.getAttributes();
  406. }
  407. }, { priority: 'high' } );
  408. // Listening to `model#insertContent` allows detecting the content insertion.
  409. // We want to apply attributes that were removed while typing over the link.
  410. this.listenTo( editor.model, 'insertContent', ( evt, [ element ] ) => {
  411. deletedContent = false;
  412. // Enabled only when typing.
  413. if ( !isTyping( editor ) ) {
  414. return;
  415. }
  416. if ( !selectionAttributes ) {
  417. return;
  418. }
  419. editor.model.change( writer => {
  420. for ( const [ attribute, value ] of selectionAttributes ) {
  421. writer.setAttribute( attribute, value, element );
  422. }
  423. } );
  424. selectionAttributes = null;
  425. }, { priority: 'high' } );
  426. }
  427. }
  428. // Checks whether selection's attributes should be copied to the new inserted text.
  429. //
  430. // @param {module:engine/model/model~Model} model
  431. // @returns {Boolean}
  432. function shouldCopyAttributes( model ) {
  433. const selection = model.document.selection;
  434. const firstPosition = selection.getFirstPosition();
  435. const lastPosition = selection.getLastPosition();
  436. const nodeAtFirstPosition = firstPosition.nodeAfter;
  437. // The text link node does not exist...
  438. if ( !nodeAtFirstPosition ) {
  439. return false;
  440. }
  441. // ...or it isn't the text node...
  442. if ( !nodeAtFirstPosition.is( 'text' ) ) {
  443. return false;
  444. }
  445. // ...or isn't the link.
  446. if ( !nodeAtFirstPosition.hasAttribute( 'linkHref' ) ) {
  447. return false;
  448. }
  449. // `textNode` = the position is inside the link element.
  450. // `nodeBefore` = the position is at the end of the link element.
  451. const nodeAtLastPosition = lastPosition.textNode || lastPosition.nodeBefore;
  452. // If both references the same node selection contains a single text node.
  453. if ( nodeAtFirstPosition === nodeAtLastPosition ) {
  454. return true;
  455. }
  456. // If nodes are not equal, maybe the link nodes has defined additional attributes inside.
  457. // First, we need to find the entire link range.
  458. const linkRange = findLinkRange( firstPosition, nodeAtFirstPosition.getAttribute( 'linkHref' ), model );
  459. // Then we can check whether selected range is inside the found link range. If so, attributes should be preserved.
  460. return linkRange.containsRange( model.createRange( firstPosition, lastPosition ), true );
  461. }
  462. // Checks whether provided changes were caused by typing.
  463. //
  464. // @params {module:core/editor/editor~Editor} editor
  465. // @returns {Boolean}
  466. function isTyping( editor ) {
  467. const input = editor.plugins.get( 'Input' );
  468. return input.isInput( editor.model.change( writer => writer.batch ) );
  469. }