headingcommand.js 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175
  1. /**
  2. * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md.
  4. */
  5. /**
  6. * @module heading/headingcommand
  7. */
  8. import Command from '@ckeditor/ckeditor5-core/src/command/command';
  9. import RootElement from '@ckeditor/ckeditor5-engine/src/model/rootelement';
  10. import camelCase from '@ckeditor/ckeditor5-utils/src/lib/lodash/camelCase';
  11. /**
  12. * The heading command. It is used by the {@link module:heading/heading~Heading heading feature} to apply headings.
  13. *
  14. * @extends module:core/command/command~Command
  15. */
  16. export default class HeadingCommand extends Command {
  17. /**
  18. * Creates an instance of the command.
  19. *
  20. * @param {module:core/editor/editor~Editor} editor Editor instance.
  21. * @param {module:heading/headingcommand~HeadingOption} option An option to be used by the command instance.
  22. */
  23. constructor( editor, option ) {
  24. super( editor );
  25. Object.assign( this, option );
  26. /**
  27. * Name of the command
  28. *
  29. * @readonly
  30. * @member {String}
  31. */
  32. this.name = camelCase( 'heading ' + this.id );
  33. /**
  34. * TODO
  35. *
  36. * @readonly
  37. * @member {}
  38. */
  39. this.set( 'value', false );
  40. // Update current value each time changes are done on document.
  41. this.listenTo( editor.document, 'changesDone', () => this._updateValue() );
  42. /**
  43. * Unique identifier of the command, also element's name in the model.
  44. * See {@link module:heading/headingcommand~HeadingOption#id}.
  45. *
  46. * @readonly
  47. * @member {String} #id
  48. */
  49. /**
  50. * Element this command creates in the view.
  51. * See {@link module:heading/headingcommand~HeadingOption#element}.
  52. *
  53. * @readonly
  54. * @member {String} #element
  55. */
  56. /**
  57. * Label of this command.
  58. * See {@link module:heading/headingcommand~HeadingOption#label}.
  59. *
  60. * @readonly
  61. * @member {String} #label
  62. */
  63. }
  64. /**
  65. * Executes command.
  66. *
  67. * @protected
  68. * @param {Object} [options] Options for executed command.
  69. * @param {module:engine/model/batch~Batch} [options.batch] Batch to collect all the change steps.
  70. * New batch will be created if this option is not set.
  71. */
  72. _doExecute( options = {} ) {
  73. const id = this.id;
  74. const doc = this.editor.document;
  75. const selection = doc.selection;
  76. const startPosition = selection.getFirstPosition();
  77. const elements = [];
  78. // Storing selection ranges and direction to fix selection after renaming. See ckeditor5-engine#367.
  79. const ranges = [ ...selection.getRanges() ];
  80. const isSelectionBackward = selection.isBackward;
  81. // Collect elements to change option.
  82. // This implementation may not be future proof but it's satisfactory at this stage.
  83. if ( selection.isCollapsed ) {
  84. const block = findTopmostBlock( startPosition );
  85. if ( block ) {
  86. elements.push( block );
  87. }
  88. } else {
  89. for ( let range of ranges ) {
  90. let startBlock = findTopmostBlock( range.start );
  91. const endBlock = findTopmostBlock( range.end, false );
  92. elements.push( startBlock );
  93. while ( startBlock !== endBlock ) {
  94. startBlock = startBlock.nextSibling;
  95. elements.push( startBlock );
  96. }
  97. }
  98. }
  99. doc.enqueueChanges( () => {
  100. const batch = options.batch || doc.batch();
  101. for ( let element of elements ) {
  102. batch.rename( element, id );
  103. }
  104. // If range's selection start/end is placed directly in renamed block - we need to restore it's position
  105. // after renaming, because renaming puts new element there.
  106. doc.selection.setRanges( ranges, isSelectionBackward );
  107. } );
  108. }
  109. /**
  110. * Updates command's {@link #value value} based on current selection.
  111. *
  112. * @private
  113. */
  114. _updateValue() {
  115. const position = this.editor.document.selection.getFirstPosition();
  116. const block = findTopmostBlock( position );
  117. if ( block ) {
  118. this.value = this.id == block.name;
  119. }
  120. }
  121. }
  122. // Looks for the topmost element in the position's ancestor (up to an element in the root).
  123. //
  124. // NOTE: This method does not check the schema directly — it assumes that only block elements can be placed directly inside
  125. // the root.
  126. //
  127. // @private
  128. // @param {engine.model.Position} position
  129. // @param {Boolean} [nodeAfter=true] When the position is placed inside the root element, this will determine if the element before
  130. // or after a given position will be returned.
  131. // @returns {engine.model.Element}
  132. function findTopmostBlock( position, nodeAfter = true ) {
  133. let parent = position.parent;
  134. // If position is placed inside root - get element after/before it.
  135. if ( parent instanceof RootElement ) {
  136. return nodeAfter ? position.nodeAfter : position.nodeBefore;
  137. }
  138. while ( !( parent.parent instanceof RootElement ) ) {
  139. parent = parent.parent;
  140. }
  141. return parent;
  142. }
  143. /**
  144. * Heading option descriptor.
  145. *
  146. * @typedef {Object} module:heading/headingcommand~HeadingOption
  147. * @property {String} id Option identifier. It will be used as the element's name in the model.
  148. * @property {String} element The name of the view element that will be used to represent the model element in the view.
  149. * @property {String} label The display name of the option.
  150. */