8
0

linkcommand.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  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/linkcommand
  7. */
  8. import Command from '@ckeditor/ckeditor5-core/src/command';
  9. import findAttributeRange from '@ckeditor/ckeditor5-typing/src/utils/findattributerange';
  10. import toMap from '@ckeditor/ckeditor5-utils/src/tomap';
  11. import Collection from '@ckeditor/ckeditor5-utils/src/collection';
  12. import first from '@ckeditor/ckeditor5-utils/src/first';
  13. import AutomaticDecorators from './utils/automaticdecorators';
  14. import { isImageAllowed } from './utils';
  15. /**
  16. * The link command. It is used by the {@link module:link/link~Link link feature}.
  17. *
  18. * @extends module:core/command~Command
  19. */
  20. export default class LinkCommand extends Command {
  21. /**
  22. * The value of the `'linkHref'` attribute if the start of the selection is located in a node with this attribute.
  23. *
  24. * @observable
  25. * @readonly
  26. * @member {Object|undefined} #value
  27. */
  28. constructor( editor ) {
  29. super( editor );
  30. /**
  31. * A collection of {@link module:link/utils~ManualDecorator manual decorators}
  32. * corresponding to the {@link module:link/link~LinkConfig#decorators decorator configuration}.
  33. *
  34. * You can consider it a model with states of manual decorators added to the currently selected link.
  35. *
  36. * @readonly
  37. * @type {module:utils/collection~Collection}
  38. */
  39. this.manualDecorators = new Collection();
  40. /**
  41. * An instance of the helper that ties together all {@link module:link/link~LinkDecoratorAutomaticDefinition}
  42. * that are used by the {@glink features/link link} and the {@glink features/image#linking-images linking images} features.
  43. *
  44. * @readonly
  45. * @type {module:link/utils~AutomaticDecorators}
  46. */
  47. this.automaticDecorators = new AutomaticDecorators();
  48. }
  49. /**
  50. * Synchronizes the state of {@link #manualDecorators} with the currently present elements in the model.
  51. */
  52. restoreManualDecoratorStates() {
  53. for ( const manualDecorator of this.manualDecorators ) {
  54. manualDecorator.value = this._getDecoratorStateFromModel( manualDecorator.id );
  55. }
  56. }
  57. /**
  58. * @inheritDoc
  59. */
  60. refresh() {
  61. const model = this.editor.model;
  62. const doc = model.document;
  63. const selectedElement = first( doc.selection.getSelectedBlocks() );
  64. // A check for the `LinkImage` plugin. If the selection contains an element, get values from the element.
  65. // Currently the selection reads attributes from text nodes only. See #7429 and #7465.
  66. if ( isImageAllowed( selectedElement, model.schema ) ) {
  67. this.value = selectedElement.getAttribute( 'linkHref' );
  68. this.isEnabled = model.schema.checkAttribute( selectedElement, 'linkHref' );
  69. } else {
  70. this.value = doc.selection.getAttribute( 'linkHref' );
  71. this.isEnabled = model.schema.checkAttributeInSelection( doc.selection, 'linkHref' );
  72. }
  73. for ( const manualDecorator of this.manualDecorators ) {
  74. manualDecorator.value = this._getDecoratorStateFromModel( manualDecorator.id );
  75. }
  76. }
  77. /**
  78. * Executes the command.
  79. *
  80. * When the selection is non-collapsed, the `linkHref` attribute will be applied to nodes inside the selection, but only to
  81. * those nodes where the `linkHref` attribute is allowed (disallowed nodes will be omitted).
  82. *
  83. * When the selection is collapsed and is not inside the text with the `linkHref` attribute, a
  84. * new {@link module:engine/model/text~Text text node} with the `linkHref` attribute will be inserted in place of the caret, but
  85. * only if such element is allowed in this place. The `_data` of the inserted text will equal the `href` parameter.
  86. * The selection will be updated to wrap the just inserted text node.
  87. *
  88. * When the selection is collapsed and inside the text with the `linkHref` attribute, the attribute value will be updated.
  89. *
  90. * # Decorators and model attribute management
  91. *
  92. * There is an optional argument to this command that applies or removes model
  93. * {@glink framework/guides/architecture/editing-engine#text-attributes text attributes} brought by
  94. * {@link module:link/utils~ManualDecorator manual link decorators}.
  95. *
  96. * Text attribute names in the model correspond to the entries in the {@link module:link/link~LinkConfig#decorators configuration}.
  97. * For every decorator configured, a model text attribute exists with the "link" prefix. For example, a `'linkMyDecorator'` attribute
  98. * corresponds to `'myDecorator'` in the configuration.
  99. *
  100. * To learn more about link decorators, check out the {@link module:link/link~LinkConfig#decorators `config.link.decorators`}
  101. * documentation.
  102. *
  103. * Here is how to manage decorator attributes with the link command:
  104. *
  105. * const linkCommand = editor.commands.get( 'link' );
  106. *
  107. * // Adding a new decorator attribute.
  108. * linkCommand.execute( 'http://example.com', {
  109. * linkIsExternal: true
  110. * } );
  111. *
  112. * // Removing a decorator attribute from the selection.
  113. * linkCommand.execute( 'http://example.com', {
  114. * linkIsExternal: false
  115. * } );
  116. *
  117. * // Adding multiple decorator attributes at the same time.
  118. * linkCommand.execute( 'http://example.com', {
  119. * linkIsExternal: true,
  120. * linkIsDownloadable: true,
  121. * } );
  122. *
  123. * // Removing and adding decorator attributes at the same time.
  124. * linkCommand.execute( 'http://example.com', {
  125. * linkIsExternal: false,
  126. * linkFoo: true,
  127. * linkIsDownloadable: false,
  128. * } );
  129. *
  130. * **Note**: If the decorator attribute name is not specified, its state remains untouched.
  131. *
  132. * **Note**: {@link module:link/unlinkcommand~UnlinkCommand#execute `UnlinkCommand#execute()`} removes all
  133. * decorator attributes.
  134. *
  135. * @fires execute
  136. * @param {String} href Link destination.
  137. * @param {Object} [manualDecoratorIds={}] The information about manual decorator attributes to be applied or removed upon execution.
  138. */
  139. execute( href, manualDecoratorIds = {} ) {
  140. const model = this.editor.model;
  141. const selection = model.document.selection;
  142. // Stores information about manual decorators to turn them on/off when command is applied.
  143. const truthyManualDecorators = [];
  144. const falsyManualDecorators = [];
  145. for ( const name in manualDecoratorIds ) {
  146. if ( manualDecoratorIds[ name ] ) {
  147. truthyManualDecorators.push( name );
  148. } else {
  149. falsyManualDecorators.push( name );
  150. }
  151. }
  152. model.change( writer => {
  153. // If selection is collapsed then update selected link or insert new one at the place of caret.
  154. if ( selection.isCollapsed ) {
  155. const position = selection.getFirstPosition();
  156. // When selection is inside text with `linkHref` attribute.
  157. if ( selection.hasAttribute( 'linkHref' ) ) {
  158. // Then update `linkHref` value.
  159. const linkRange = findAttributeRange( position, 'linkHref', selection.getAttribute( 'linkHref' ), model );
  160. writer.setAttribute( 'linkHref', href, linkRange );
  161. truthyManualDecorators.forEach( item => {
  162. writer.setAttribute( item, true, linkRange );
  163. } );
  164. falsyManualDecorators.forEach( item => {
  165. writer.removeAttribute( item, linkRange );
  166. } );
  167. // Put the selection at the end of the updated link.
  168. writer.setSelection( writer.createPositionAfter( linkRange.end.nodeBefore ) );
  169. }
  170. // If not then insert text node with `linkHref` attribute in place of caret.
  171. // However, since selection in collapsed, attribute value will be used as data for text node.
  172. // So, if `href` is empty, do not create text node.
  173. else if ( href !== '' ) {
  174. const attributes = toMap( selection.getAttributes() );
  175. attributes.set( 'linkHref', href );
  176. truthyManualDecorators.forEach( item => {
  177. attributes.set( item, true );
  178. } );
  179. const node = writer.createText( href, attributes );
  180. model.insertContent( node, position );
  181. // Put the selection at the end of the inserted link.
  182. writer.setSelection( writer.createPositionAfter( node ) );
  183. }
  184. // Remove the `linkHref` attribute and all link decorators from the selection.
  185. // It stops adding a new content into the link element.
  186. [ 'linkHref', ...truthyManualDecorators, ...falsyManualDecorators ].forEach( item => {
  187. writer.removeSelectionAttribute( item );
  188. } );
  189. } else {
  190. // If selection has non-collapsed ranges, we change attribute on nodes inside those ranges
  191. // omitting nodes where the `linkHref` attribute is disallowed.
  192. const ranges = model.schema.getValidRanges( selection.getRanges(), 'linkHref' );
  193. // But for the first, check whether the `linkHref` attribute is allowed on selected blocks (e.g. the "image" element).
  194. const allowedRanges = [];
  195. for ( const element of selection.getSelectedBlocks() ) {
  196. if ( model.schema.checkAttribute( element, 'linkHref' ) ) {
  197. allowedRanges.push( writer.createRangeOn( element ) );
  198. }
  199. }
  200. // Ranges that accept the `linkHref` attribute. Since we will iterate over `allowedRanges`, let's clone it.
  201. const rangesToUpdate = allowedRanges.slice();
  202. // For all selection ranges we want to check whether given range is inside an element that accepts the `linkHref` attribute.
  203. // If so, we don't want to propagate applying the attribute to its children.
  204. for ( const range of ranges ) {
  205. if ( this._isRangeToUpdate( range, allowedRanges ) ) {
  206. rangesToUpdate.push( range );
  207. }
  208. }
  209. for ( const range of rangesToUpdate ) {
  210. writer.setAttribute( 'linkHref', href, range );
  211. truthyManualDecorators.forEach( item => {
  212. writer.setAttribute( item, true, range );
  213. } );
  214. falsyManualDecorators.forEach( item => {
  215. writer.removeAttribute( item, range );
  216. } );
  217. }
  218. }
  219. } );
  220. }
  221. /**
  222. * Provides information whether a decorator with a given name is present in the currently processed selection.
  223. *
  224. * @private
  225. * @param {String} decoratorName The name of the manual decorator used in the model
  226. * @returns {Boolean} The information whether a given decorator is currently present in the selection.
  227. */
  228. _getDecoratorStateFromModel( decoratorName ) {
  229. const model = this.editor.model;
  230. const doc = model.document;
  231. const selectedElement = first( doc.selection.getSelectedBlocks() );
  232. // A check for the `LinkImage` plugin. If the selection contains an element, get values from the element.
  233. // Currently the selection reads attributes from text nodes only. See #7429 and #7465.
  234. if ( isImageAllowed( selectedElement, model.schema ) ) {
  235. return selectedElement.getAttribute( decoratorName );
  236. }
  237. return doc.selection.getAttribute( decoratorName );
  238. }
  239. /**
  240. * Checks whether specified `range` is inside an element that accepts the `linkHref` attribute.
  241. *
  242. * @private
  243. * @param {module:engine/view/range~Range} range A range to check.
  244. * @param {Array.<module:engine/view/range~Range>} allowedRanges An array of ranges created on elements where the attribute is accepted.
  245. * @returns {Boolean}
  246. */
  247. _isRangeToUpdate( range, allowedRanges ) {
  248. for ( const allowedRange of allowedRanges ) {
  249. // A range is inside an element that will have the `linkHref` attribute. Do not modify its nodes.
  250. if ( allowedRange.containsRange( range ) ) {
  251. return false;
  252. }
  253. }
  254. return true;
  255. }
  256. }