listcommand.js 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. /**
  2. * @license Copyright (c) 2003-2016, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md.
  4. */
  5. /**
  6. * @module list/listcommand
  7. */
  8. import Command from 'ckeditor5-core/src/command/command';
  9. import { getClosestListItem, getSelectedBlocks, getPositionBeforeBlock } from './utils';
  10. /**
  11. * The list command. It is used by the {@link module:list/list~List list feature}.
  12. *
  13. * @extends module:core/command/command~Command
  14. */
  15. export default class ListCommand extends Command {
  16. /**
  17. * Creates an instance of the command.
  18. *
  19. * @param {module:core/editor/editor~Editor} editor Editor instance.
  20. * @param {'numbered'|'bulleted'} type List type that will be handled by this command.
  21. */
  22. constructor( editor, type ) {
  23. super( editor );
  24. /**
  25. * The type of list created by the command.
  26. *
  27. * @readonly
  28. * @member {'numbered'|'bulleted'}
  29. */
  30. this.type = type == 'bulleted' ? 'bulleted' : 'numbered';
  31. /**
  32. * Flag indicating whether the command is active, which means that selection starts in a list of the same type.
  33. *
  34. * @observable
  35. * @member {Boolean}
  36. */
  37. this.set( 'value', false );
  38. const changeCallback = () => {
  39. this.refreshValue();
  40. this.refreshState();
  41. };
  42. // Listen on selection and document changes and set the current command's value.
  43. this.listenTo( editor.document.selection, 'change:range', changeCallback );
  44. this.listenTo( editor.document, 'changesDone', changeCallback );
  45. }
  46. /**
  47. * Sets command's value based on the document selection.
  48. */
  49. refreshValue() {
  50. const position = this.editor.document.selection.getFirstPosition();
  51. // Check whether closest `listItem` ancestor of the position has a correct type.
  52. const listItem = getClosestListItem( position );
  53. this.value = listItem !== null && listItem.getAttribute( 'type' ) == this.type;
  54. }
  55. /**
  56. * Executes command.
  57. *
  58. * @protected
  59. * @param {Object} [options] Options for executed command.
  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. const document = this.editor.document;
  65. const blocks = getSelectedBlocks( document.selection, document.schema );
  66. // Whether we are turning off some items.
  67. const turnOff = this.value === true;
  68. // If we are turning off items, we are going to rename them to paragraphs.
  69. document.enqueueChanges( () => {
  70. const batch = options.batch || document.batch();
  71. // If part of a list got turned off, we need to handle (outdent) all of sub-items of the last turned-off item.
  72. // To be sure that model is all the time in a good state, we first fix items below turned-off item.
  73. if ( turnOff ) {
  74. // Start from the model item that is just after the last turned-off item.
  75. let next = blocks[ blocks.length - 1 ].nextSibling;
  76. let currentIndent = Number.POSITIVE_INFINITY;
  77. let changes = [];
  78. // Correct indent of all items after the last turned off item.
  79. // Rules that should be followed:
  80. // 1. All direct sub-items of turned-off item should become indent 0, because the first item after it
  81. // will be the first item of a new list. Other items are at the same level, so should have same 0 index.
  82. // 2. All items with indent lower than indent of turned-off item should become indent 0, because they
  83. // should not end up as a child of any of list items that they were not children of before.
  84. // 3. All other items should have their indent changed relatively to it's parent.
  85. //
  86. // For example:
  87. // 1 * --------
  88. // 2 * --------
  89. // 3 * -------- <- this is turned off.
  90. // 4 * -------- <- this has to become indent = 0, because it will be first item on a new list.
  91. // 5 * -------- <- this should be still be a child of item above, so indent = 1.
  92. // 6 * -------- <- this also has to become indent = 0, because it shouldn't end up as a child of any of items above.
  93. // 7 * -------- <- this should be still be a child of item above, so indent = 1.
  94. // 8 * -------- <- this has to become indent = 0.
  95. // 9 * -------- <- this should still be a child of item above, so indent = 1.
  96. // 10 * -------- <- this should still be a child of item above, so indent = 2.
  97. // 11 * -------- <- this should still be at the same level as item above, so indent = 2.
  98. // 12 * -------- <- this and all below are left unchanged.
  99. // 13 * --------
  100. // 14 * --------
  101. //
  102. // After turning off 3 the list becomes:
  103. //
  104. // 1 * --------
  105. // 2 * --------
  106. //
  107. // 3 --------
  108. //
  109. // 4 * --------
  110. // 5 * --------
  111. // 6 * --------
  112. // 7 * --------
  113. // 8 * --------
  114. // 9 * --------
  115. // 10 * --------
  116. // 11 * --------
  117. // 12 * --------
  118. // 13 * --------
  119. // 14 * --------
  120. //
  121. // Thanks to this algorithm no lists are mismatched and no items get unexpected children/parent, while
  122. // those parent-child connection which are possible to maintain are still maintained. It's worth noting
  123. // that this is the same effect that we would be get by multiple use of outdent command. However doing
  124. // it like this is much more efficient because it's less operation (less memory usage, easier OT) and
  125. // less conversion (faster).
  126. while ( next && next.name == 'listItem' && next.getAttribute( 'indent' ) !== 0 ) {
  127. // Check each next list item, as long as its indent is bigger than 0.
  128. // If the indent is 0 we are not going to change anything anyway.
  129. const indent = next.getAttribute( 'indent' );
  130. // We check if that's item indent is lower as current relative indent.
  131. if ( indent < currentIndent ) {
  132. // If it is, current relative indent becomes that indent.
  133. currentIndent = indent;
  134. }
  135. // Fix indent relatively to current relative indent.
  136. // Note, that if we just changed the current relative indent, the newIndent will be equal to 0.
  137. const newIndent = indent - currentIndent;
  138. // Save the entry in changes array. We do not apply it at the moment, because we will need to
  139. // reverse the changes so the last item is changed first.
  140. // This is to keep model in correct state all the time.
  141. changes.push( { element: next, indent: newIndent } );
  142. // Find next item.
  143. next = next.nextSibling;
  144. }
  145. changes = changes.reverse();
  146. for ( let item of changes ) {
  147. batch.setAttribute( item.element, 'indent', item.indent );
  148. }
  149. }
  150. // Phew! Now it will be easier :).
  151. // For each block element that was in the selection, we will either: turn it to list item,
  152. // turn it to paragraph, or change it's type. Or leave it as it is.
  153. for ( let element of blocks ) {
  154. if ( turnOff && element.name == 'listItem' ) {
  155. // We are turning off and the element is a `listItem` - it should be converted to `paragraph`.
  156. // The order is important to keep model in correct state.
  157. batch.rename( element, 'paragraph' ).removeAttribute( element, 'type' ).removeAttribute( element, 'indent' );
  158. } else if ( !turnOff && element.name != 'listItem' ) {
  159. // We are turning on and the element is not a `listItem` - it should be converted to `listItem`.
  160. // The order is important to keep model in correct state.
  161. batch.setAttribute( element, 'type', this.type ).setAttribute( element, 'indent', 0 ).rename( element, 'listItem' );
  162. } else if ( !turnOff && element.name == 'listItem' && element.getAttribute( 'type' ) != this.type ) {
  163. // We are turning on and the element is a `listItem` but has different type - change type.
  164. batch.setAttribute( element, 'type', this.type );
  165. }
  166. }
  167. } );
  168. }
  169. /**
  170. * @inheritDoc
  171. */
  172. _checkEnabled() {
  173. // If command is enabled it means that we are in list item, so the command should be enabled.
  174. if ( this.value ) {
  175. return true;
  176. }
  177. const selection = this.editor.document.selection;
  178. const schema = this.editor.document.schema;
  179. const position = getPositionBeforeBlock( selection.getFirstPosition(), schema );
  180. // Otherwise, check if list item can be inserted at the position start.
  181. return schema.check( { name: 'listItem', inside: position, attributes: [ 'type', 'indent' ] } );
  182. }
  183. }