blockquotecommand.js 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  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 block-quote/blockquotecommand
  7. */
  8. import Command from '@ckeditor/ckeditor5-core/src/command';
  9. import first from '@ckeditor/ckeditor5-utils/src/first';
  10. /**
  11. * The block quote command plugin.
  12. *
  13. * @extends module:core/command~Command
  14. */
  15. export default class BlockQuoteCommand extends Command {
  16. /**
  17. * Whether the selection starts in a block quote.
  18. *
  19. * @observable
  20. * @readonly
  21. * @member {Boolean} #value
  22. */
  23. /**
  24. * @inheritDoc
  25. */
  26. refresh() {
  27. this.value = this._getValue();
  28. this.isEnabled = this._checkEnabled();
  29. }
  30. /**
  31. * Executes the command. When the command {@link #value is on}, all top-most block quotes within
  32. * the selection will be removed. If it is off, all selected blocks will be wrapped with
  33. * a block quote.
  34. *
  35. * @fires execute
  36. * @param {Object} [options] Command options.
  37. * @param {Boolean} [options.forceValue] If set, it will force the command behavior. If `true`, the command will apply a block quote,
  38. * otherwise the command will remove the block quote. If not set, the command will act basing on its current value.
  39. */
  40. execute( options = {} ) {
  41. const model = this.editor.model;
  42. const schema = model.schema;
  43. const selection = model.document.selection;
  44. const blocks = Array.from( selection.getSelectedBlocks() );
  45. const value = ( options.forceValue === undefined ) ? !this.value : options.forceValue;
  46. model.change( writer => {
  47. if ( !value ) {
  48. this._removeQuote( writer, blocks.filter( findQuote ) );
  49. } else {
  50. const blocksToQuote = blocks.filter( block => {
  51. // Already quoted blocks needs to be considered while quoting too
  52. // in order to reuse their <bQ> elements.
  53. return findQuote( block ) || checkCanBeQuoted( schema, block );
  54. } );
  55. this._applyQuote( writer, blocksToQuote );
  56. }
  57. } );
  58. }
  59. /**
  60. * Checks the command's {@link #value}.
  61. *
  62. * @private
  63. * @returns {Boolean} The current value.
  64. */
  65. _getValue() {
  66. const selection = this.editor.model.document.selection;
  67. const firstBlock = first( selection.getSelectedBlocks() );
  68. // In the current implementation, the block quote must be an immediate parent of a block element.
  69. return !!( firstBlock && findQuote( firstBlock ) );
  70. }
  71. /**
  72. * Checks whether the command can be enabled in the current context.
  73. *
  74. * @private
  75. * @returns {Boolean} Whether the command should be enabled.
  76. */
  77. _checkEnabled() {
  78. if ( this.value ) {
  79. return true;
  80. }
  81. const selection = this.editor.model.document.selection;
  82. const schema = this.editor.model.schema;
  83. const firstBlock = first( selection.getSelectedBlocks() );
  84. if ( !firstBlock ) {
  85. return false;
  86. }
  87. return checkCanBeQuoted( schema, firstBlock );
  88. }
  89. /**
  90. * Removes the quote from given blocks.
  91. *
  92. * If blocks which are supposed to be "unquoted" are in the middle of a quote,
  93. * start it or end it, then the quote will be split (if needed) and the blocks
  94. * will be moved out of it, so other quoted blocks remained quoted.
  95. *
  96. * @private
  97. * @param {module:engine/model/writer~Writer} writer
  98. * @param {Array.<module:engine/model/element~Element>} blocks
  99. */
  100. _removeQuote( writer, blocks ) {
  101. // Unquote all groups of block. Iterate in the reverse order to not break following ranges.
  102. getRangesOfBlockGroups( writer, blocks ).reverse().forEach( groupRange => {
  103. if ( groupRange.start.isAtStart && groupRange.end.isAtEnd ) {
  104. writer.unwrap( groupRange.start.parent );
  105. return;
  106. }
  107. // The group of blocks are at the beginning of an <bQ> so let's move them left (out of the <bQ>).
  108. if ( groupRange.start.isAtStart ) {
  109. const positionBefore = writer.createPositionBefore( groupRange.start.parent );
  110. writer.move( groupRange, positionBefore );
  111. return;
  112. }
  113. // The blocks are in the middle of an <bQ> so we need to split the <bQ> after the last block
  114. // so we move the items there.
  115. if ( !groupRange.end.isAtEnd ) {
  116. writer.split( groupRange.end );
  117. }
  118. // Now we are sure that groupRange.end.isAtEnd is true, so let's move the blocks right.
  119. const positionAfter = writer.createPositionAfter( groupRange.end.parent );
  120. writer.move( groupRange, positionAfter );
  121. } );
  122. }
  123. /**
  124. * Applies the quote to given blocks.
  125. *
  126. * @private
  127. * @param {module:engine/model/writer~Writer} writer
  128. * @param {Array.<module:engine/model/element~Element>} blocks
  129. */
  130. _applyQuote( writer, blocks ) {
  131. const quotesToMerge = [];
  132. // Quote all groups of block. Iterate in the reverse order to not break following ranges.
  133. getRangesOfBlockGroups( writer, blocks ).reverse().forEach( groupRange => {
  134. let quote = findQuote( groupRange.start );
  135. if ( !quote ) {
  136. quote = writer.createElement( 'blockQuote' );
  137. writer.wrap( groupRange, quote );
  138. }
  139. quotesToMerge.push( quote );
  140. } );
  141. // Merge subsequent <bQ> elements. Reverse the order again because this time we want to go through
  142. // the <bQ> elements in the source order (due to how merge works – it moves the right element's content
  143. // to the first element and removes the right one. Since we may need to merge a couple of subsequent `<bQ>` elements
  144. // we want to keep the reference to the first (furthest left) one.
  145. quotesToMerge.reverse().reduce( ( currentQuote, nextQuote ) => {
  146. if ( currentQuote.nextSibling == nextQuote ) {
  147. writer.merge( writer.createPositionAfter( currentQuote ) );
  148. return currentQuote;
  149. }
  150. return nextQuote;
  151. } );
  152. }
  153. }
  154. function findQuote( elementOrPosition ) {
  155. return elementOrPosition.parent.name == 'blockQuote' ? elementOrPosition.parent : null;
  156. }
  157. // Returns a minimal array of ranges containing groups of subsequent blocks.
  158. //
  159. // content: abcdefgh
  160. // blocks: [ a, b, d, f, g, h ]
  161. // output ranges: [ab]c[d]e[fgh]
  162. //
  163. // @param {Array.<module:engine/model/element~Element>} blocks
  164. // @returns {Array.<module:engine/model/range~Range>}
  165. function getRangesOfBlockGroups( writer, blocks ) {
  166. let startPosition;
  167. let i = 0;
  168. const ranges = [];
  169. while ( i < blocks.length ) {
  170. const block = blocks[ i ];
  171. const nextBlock = blocks[ i + 1 ];
  172. if ( !startPosition ) {
  173. startPosition = writer.createPositionBefore( block );
  174. }
  175. if ( !nextBlock || block.nextSibling != nextBlock ) {
  176. ranges.push( writer.createRange( startPosition, writer.createPositionAfter( block ) ) );
  177. startPosition = null;
  178. }
  179. i++;
  180. }
  181. return ranges;
  182. }
  183. // Checks whether <bQ> can wrap the block.
  184. function checkCanBeQuoted( schema, block ) {
  185. // TMP will be replaced with schema.checkWrap().
  186. const isBQAllowed = schema.checkChild( block.parent, 'blockQuote' );
  187. const isBlockAllowedInBQ = schema.checkChild( [ '$root', 'blockQuote' ], block );
  188. return isBQAllowed && isBlockAllowedInBQ;
  189. }