8
0

linkediting.js 18 KB

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