8
0

headingcommand.js 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  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. /**
  11. * The heading command. It is used by the {@link module:heading/heading~Heading heading feature} to apply headings.
  12. *
  13. * @extends module:core/command/command~Command
  14. */
  15. export default class HeadingCommand extends Command {
  16. /**
  17. * Creates an instance of the command.
  18. *
  19. * @param {module:core/editor/editor~Editor} editor Editor instance.
  20. * @param {Array.<module:heading/headingcommand~HeadingOption>} options Heading options to be used by the command instance.
  21. */
  22. constructor( editor, options, defaultOptionId ) {
  23. super( editor );
  24. /**
  25. * Heading options used by this command.
  26. *
  27. * @readonly
  28. * @member {module:heading/headingcommand~HeadingOption}
  29. */
  30. this.options = options;
  31. /**
  32. * The id of the default option among {@link #options}.
  33. *
  34. * @readonly
  35. * @private
  36. * @member {module:heading/headingcommand~HeadingOption#id}
  37. */
  38. this._defaultOptionId = defaultOptionId;
  39. /**
  40. * The currently selected heading option.
  41. *
  42. * @readonly
  43. * @observable
  44. * @member {module:heading/headingcommand~HeadingOption} #value
  45. */
  46. this.set( 'value', this.defaultOption );
  47. // Update current value each time changes are done on document.
  48. this.listenTo( editor.document, 'changesDone', () => this._updateValue() );
  49. }
  50. /**
  51. * The default option.
  52. *
  53. * @member {module:heading/headingcommand~HeadingOption} #defaultOption
  54. */
  55. get defaultOption() {
  56. // See https://github.com/ckeditor/ckeditor5/issues/98.
  57. return this._getOptionById( this._defaultOptionId );
  58. }
  59. /**
  60. * Executes command.
  61. *
  62. * @protected
  63. * @param {Object} [options] Options for executed command.
  64. * @param {String} [options.id] The identifier of the heading option that should be applied. It should be one of the
  65. * {@link module:heading/headingcommand~HeadingOption heading options} provided to the command constructor. If this parameter is not
  66. * provided,
  67. * the value from {@link #defaultOption defaultOption} will be used.
  68. * @param {module:engine/model/batch~Batch} [options.batch] Batch to collect all the change steps.
  69. * New batch will be created if this option is not set.
  70. */
  71. _doExecute( options = {} ) {
  72. // TODO: What should happen if option is not found?
  73. const id = options.id || this.defaultOption.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. // If current option is same as new option - toggle already applied option back to default one.
  82. const shouldRemove = ( id === this.value.id );
  83. // Collect elements to change option.
  84. // This implementation may not be future proof but it's satisfactory at this stage.
  85. if ( selection.isCollapsed ) {
  86. const block = findTopmostBlock( startPosition );
  87. if ( block ) {
  88. elements.push( block );
  89. }
  90. } else {
  91. for ( let range of ranges ) {
  92. let startBlock = findTopmostBlock( range.start );
  93. const endBlock = findTopmostBlock( range.end, false );
  94. elements.push( startBlock );
  95. while ( startBlock !== endBlock ) {
  96. startBlock = startBlock.nextSibling;
  97. elements.push( startBlock );
  98. }
  99. }
  100. }
  101. doc.enqueueChanges( () => {
  102. const batch = options.batch || doc.batch();
  103. for ( let element of elements ) {
  104. // When removing applied option.
  105. if ( shouldRemove ) {
  106. if ( element.name === id ) {
  107. batch.rename( element, this.defaultOption.id );
  108. }
  109. }
  110. // When applying new option.
  111. else {
  112. batch.rename( element, id );
  113. }
  114. }
  115. // If range's selection start/end is placed directly in renamed block - we need to restore it's position
  116. // after renaming, because renaming puts new element there.
  117. doc.selection.setRanges( ranges, isSelectionBackward );
  118. } );
  119. }
  120. /**
  121. * Returns the option by a given ID.
  122. *
  123. * @private
  124. * @param {String} id
  125. * @returns {module:heading/headingcommand~HeadingOption}
  126. */
  127. _getOptionById( id ) {
  128. return this.options.find( item => item.id === id ) || this.defaultOption;
  129. }
  130. /**
  131. * Updates command's {@link #value value} based on current selection.
  132. *
  133. * @private
  134. */
  135. _updateValue() {
  136. const position = this.editor.document.selection.getFirstPosition();
  137. const block = findTopmostBlock( position );
  138. if ( block ) {
  139. this.value = this._getOptionById( block.name );
  140. }
  141. }
  142. }
  143. // Looks for the topmost element in the position's ancestor (up to an element in the root).
  144. //
  145. // NOTE: This method does not check the schema directly &mdash; it assumes that only block elements can be placed directly inside
  146. // the root.
  147. //
  148. // @private
  149. // @param {engine.model.Position} position
  150. // @param {Boolean} [nodeAfter=true] When the position is placed inside the root element, this will determine if the element before
  151. // or after a given position will be returned.
  152. // @returns {engine.model.Element}
  153. function findTopmostBlock( position, nodeAfter = true ) {
  154. let parent = position.parent;
  155. // If position is placed inside root - get element after/before it.
  156. if ( parent instanceof RootElement ) {
  157. return nodeAfter ? position.nodeAfter : position.nodeBefore;
  158. }
  159. while ( !( parent.parent instanceof RootElement ) ) {
  160. parent = parent.parent;
  161. }
  162. return parent;
  163. }
  164. /**
  165. * Heading option descriptor.
  166. *
  167. * @typedef {Object} module:heading/headingcommand~HeadingOption
  168. * @property {String} id Option identifier. It will be used as the element's name in the model.
  169. * @property {String} element The name of the view element that will be used to represent the model element in the view.
  170. * @property {String} label The display name of the option.
  171. */