headingcommand.js 5.8 KB

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