listcommand.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  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 list/listcommand
  7. */
  8. import Command from '@ckeditor/ckeditor5-core/src/command';
  9. import first from '@ckeditor/ckeditor5-utils/src/first';
  10. /**
  11. * The list command. It is used by the {@link module:list/list~List list feature}.
  12. *
  13. * @extends module:core/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 The 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 the list created by the command.
  26. *
  27. * @readonly
  28. * @member {'numbered'|'bulleted'|'todo'}
  29. */
  30. this.type = type;
  31. /**
  32. * A flag indicating whether the command is active, which means that the selection starts in a list of the same type.
  33. *
  34. * @observable
  35. * @readonly
  36. * @member {Boolean} #value
  37. */
  38. }
  39. /**
  40. * @inheritDoc
  41. */
  42. refresh() {
  43. this.value = this._getValue();
  44. this.isEnabled = this._checkEnabled();
  45. }
  46. /**
  47. * Executes the command.
  48. *
  49. * @protected
  50. */
  51. execute() {
  52. const model = this.editor.model;
  53. const document = model.document;
  54. const blocks = Array.from( document.selection.getSelectedBlocks() )
  55. .filter( block => checkCanBecomeListItem( block, model.schema ) );
  56. // Whether we are turning off some items.
  57. const turnOff = this.value === true;
  58. // If we are turning off items, we are going to rename them to paragraphs.
  59. model.change( writer => {
  60. // If part of a list got turned off, we need to handle (outdent) all of sub-items of the last turned-off item.
  61. // To be sure that model is all the time in a good state, we first fix items below turned-off item.
  62. if ( turnOff ) {
  63. // Start from the model item that is just after the last turned-off item.
  64. let next = blocks[ blocks.length - 1 ].nextSibling;
  65. let currentIndent = Number.POSITIVE_INFINITY;
  66. let changes = [];
  67. // Correct indent of all items after the last turned off item.
  68. // Rules that should be followed:
  69. // 1. All direct sub-items of turned-off item should become indent 0, because the first item after it
  70. // will be the first item of a new list. Other items are at the same level, so should have same 0 index.
  71. // 2. All items with indent lower than indent of turned-off item should become indent 0, because they
  72. // should not end up as a child of any of list items that they were not children of before.
  73. // 3. All other items should have their indent changed relatively to it's parent.
  74. //
  75. // For example:
  76. // 1 * --------
  77. // 2 * --------
  78. // 3 * -------- <-- this is turned off.
  79. // 4 * -------- <-- this has to become indent = 0, because it will be first item on a new list.
  80. // 5 * -------- <-- this should be still be a child of item above, so indent = 1.
  81. // 6 * -------- <-- this has to become indent = 0, because it should not be a child of any of items above.
  82. // 7 * -------- <-- this should be still be a child of item above, so indent = 1.
  83. // 8 * -------- <-- this has to become indent = 0.
  84. // 9 * -------- <-- this should still be a child of item above, so indent = 1.
  85. // 10 * -------- <-- this should still be a child of item above, so indent = 2.
  86. // 11 * -------- <-- this should still be at the same level as item above, so indent = 2.
  87. // 12 * -------- <-- this and all below are left unchanged.
  88. // 13 * --------
  89. // 14 * --------
  90. //
  91. // After turning off 3 the list becomes:
  92. //
  93. // 1 * --------
  94. // 2 * --------
  95. //
  96. // 3 --------
  97. //
  98. // 4 * --------
  99. // 5 * --------
  100. // 6 * --------
  101. // 7 * --------
  102. // 8 * --------
  103. // 9 * --------
  104. // 10 * --------
  105. // 11 * --------
  106. // 12 * --------
  107. // 13 * --------
  108. // 14 * --------
  109. //
  110. // Thanks to this algorithm no lists are mismatched and no items get unexpected children/parent, while
  111. // those parent-child connection which are possible to maintain are still maintained. It's worth noting
  112. // that this is the same effect that we would be get by multiple use of outdent command. However doing
  113. // it like this is much more efficient because it's less operation (less memory usage, easier OT) and
  114. // less conversion (faster).
  115. while ( next && next.name == 'listItem' && next.getAttribute( 'listIndent' ) !== 0 ) {
  116. // Check each next list item, as long as its indent is bigger than 0.
  117. // If the indent is 0 we are not going to change anything anyway.
  118. const indent = next.getAttribute( 'listIndent' );
  119. // We check if that's item indent is lower as current relative indent.
  120. if ( indent < currentIndent ) {
  121. // If it is, current relative indent becomes that indent.
  122. currentIndent = indent;
  123. }
  124. // Fix indent relatively to current relative indent.
  125. // Note, that if we just changed the current relative indent, the newIndent will be equal to 0.
  126. const newIndent = indent - currentIndent;
  127. // Save the entry in changes array. We do not apply it at the moment, because we will need to
  128. // reverse the changes so the last item is changed first.
  129. // This is to keep model in correct state all the time.
  130. changes.push( { element: next, listIndent: newIndent } );
  131. // Find next item.
  132. next = next.nextSibling;
  133. }
  134. changes = changes.reverse();
  135. for ( const item of changes ) {
  136. writer.setAttribute( 'listIndent', item.listIndent, item.element );
  137. }
  138. }
  139. // If we are turning on, we might change some items that are already `listItem`s but with different type.
  140. // Changing one nested list item to other type should also trigger changing all its siblings so the
  141. // whole nested list is of the same type.
  142. // Example (assume changing to numbered list):
  143. // * ------ <-- do not fix, top level item
  144. // * ------ <-- fix, because latter list item of this item's list is changed
  145. // * ------ <-- do not fix, item is not affected (different list)
  146. // * ------ <-- fix, because latter list item of this item's list is changed
  147. // * ------ <-- fix, because latter list item of this item's list is changed
  148. // * ---[-- <-- already in selection
  149. // * ------ <-- already in selection
  150. // * ------ <-- already in selection
  151. // * ------ <-- already in selection, but does not cause other list items to change because is top-level
  152. // * ---]-- <-- already in selection
  153. // * ------ <-- fix, because preceding list item of this item's list is changed
  154. // * ------ <-- do not fix, item is not affected (different list)
  155. // * ------ <-- do not fix, top level item
  156. if ( !turnOff ) {
  157. // Find lowest indent among selected items. This will be indicator what is the indent of
  158. // top-most list affected by the command.
  159. let lowestIndent = Number.POSITIVE_INFINITY;
  160. for ( const item of blocks ) {
  161. if ( item.is( 'element', 'listItem' ) && item.getAttribute( 'listIndent' ) < lowestIndent ) {
  162. lowestIndent = item.getAttribute( 'listIndent' );
  163. }
  164. }
  165. // Do not execute the fix for top-level lists.
  166. lowestIndent = lowestIndent === 0 ? 1 : lowestIndent;
  167. // Fix types of list items that are "before" the selected blocks.
  168. _fixType( blocks, true, lowestIndent );
  169. // Fix types of list items that are "after" the selected blocks.
  170. _fixType( blocks, false, lowestIndent );
  171. }
  172. // Phew! Now it will be easier :).
  173. // For each block element that was in the selection, we will either: turn it to list item,
  174. // turn it to paragraph, or change it's type. Or leave it as it is.
  175. // Do it in reverse as there might be multiple blocks (same as with changing indents).
  176. for ( const element of blocks.reverse() ) {
  177. if ( turnOff && element.name == 'listItem' ) {
  178. // We are turning off and the element is a `listItem` - it should be converted to `paragraph`.
  179. // List item specific attributes are removed by post fixer.
  180. writer.rename( element, 'paragraph' );
  181. } else if ( !turnOff && element.name != 'listItem' ) {
  182. // We are turning on and the element is not a `listItem` - it should be converted to `listItem`.
  183. // The order of operations is important to keep model in correct state.
  184. writer.setAttributes( { listType: this.type, listIndent: 0 }, element );
  185. writer.rename( element, 'listItem' );
  186. } else if ( !turnOff && element.name == 'listItem' && element.getAttribute( 'listType' ) != this.type ) {
  187. // We are turning on and the element is a `listItem` but has different type - change it's type and
  188. // type of it's all siblings that have same indent.
  189. writer.setAttribute( 'listType', this.type, element );
  190. }
  191. }
  192. /**
  193. * Event fired by the {@link #execute} method.
  194. *
  195. * It allows to execute an action after executing the {@link ~ListCommand#execute} method, e.g. adjusting
  196. * attributes of changed blocks.
  197. *
  198. * @protected
  199. * @event _executeCleanup
  200. */
  201. this.fire( '_executeCleanup', blocks );
  202. } );
  203. }
  204. /**
  205. * Checks the command's {@link #value}.
  206. *
  207. * @private
  208. * @returns {Boolean} The current value.
  209. */
  210. _getValue() {
  211. // Check whether closest `listItem` ancestor of the position has a correct type.
  212. const listItem = first( this.editor.model.document.selection.getSelectedBlocks() );
  213. return !!listItem && listItem.is( 'element', 'listItem' ) && listItem.getAttribute( 'listType' ) == this.type;
  214. }
  215. /**
  216. * Checks whether the command can be enabled in the current context.
  217. *
  218. * @private
  219. * @returns {Boolean} Whether the command should be enabled.
  220. */
  221. _checkEnabled() {
  222. // If command value is true it means that we are in list item, so the command should be enabled.
  223. if ( this.value ) {
  224. return true;
  225. }
  226. const selection = this.editor.model.document.selection;
  227. const schema = this.editor.model.schema;
  228. const firstBlock = first( selection.getSelectedBlocks() );
  229. if ( !firstBlock ) {
  230. return false;
  231. }
  232. // Otherwise, check if list item can be inserted at the position start.
  233. return checkCanBecomeListItem( firstBlock, schema );
  234. }
  235. }
  236. // Helper function used when one or more list item have their type changed. Fixes type of other list items
  237. // that are affected by the change (are in same lists) but are not directly in selection. The function got extracted
  238. // not to duplicated code, as same fix has to be performed before and after selection.
  239. //
  240. // @param {Array.<module:engine/model/node~Node>} blocks Blocks that are in selection.
  241. // @param {Boolean} isBackward Specified whether fix will be applied for blocks before first selected block (`true`)
  242. // or blocks after last selected block (`false`).
  243. // @param {Number} lowestIndent Lowest indent among selected blocks.
  244. function _fixType( blocks, isBackward, lowestIndent ) {
  245. // We need to check previous sibling of first changed item and next siblings of last changed item.
  246. const startingItem = isBackward ? blocks[ 0 ] : blocks[ blocks.length - 1 ];
  247. if ( startingItem.is( 'element', 'listItem' ) ) {
  248. let item = startingItem[ isBackward ? 'previousSibling' : 'nextSibling' ];
  249. // During processing items, keeps the lowest indent of already processed items.
  250. // This saves us from changing too many items.
  251. // Following example is for going forward as it is easier to read, however same applies to going backward.
  252. // * ------
  253. // * ------
  254. // * --[---
  255. // * ------ <-- `lowestIndent` should be 1
  256. // * --]--- <-- `startingItem`, `currentIndent` = 2, `lowestIndent` == 1
  257. // * ------ <-- should be fixed, `indent` == 2 == `currentIndent`
  258. // * ------ <-- should be fixed, set `currentIndent` to 1, `indent` == 1 == `currentIndent`
  259. // * ------ <-- should not be fixed, item is in different list, `indent` = 2, `indent` != `currentIndent`
  260. // * ------ <-- should be fixed, `indent` == 1 == `currentIndent`
  261. // * ------ <-- break loop (`indent` < `lowestIndent`)
  262. let currentIndent = startingItem.getAttribute( 'listIndent' );
  263. // Look back until a list item with indent lower than reference `lowestIndent`.
  264. // That would be the parent of nested sublist which contains item having `lowestIndent`.
  265. while ( item && item.is( 'element', 'listItem' ) && item.getAttribute( 'listIndent' ) >= lowestIndent ) {
  266. if ( currentIndent > item.getAttribute( 'listIndent' ) ) {
  267. currentIndent = item.getAttribute( 'listIndent' );
  268. }
  269. // Found an item that is in the same nested sublist.
  270. if ( item.getAttribute( 'listIndent' ) == currentIndent ) {
  271. // Just add the item to selected blocks like it was selected by the user.
  272. blocks[ isBackward ? 'unshift' : 'push' ]( item );
  273. }
  274. item = item[ isBackward ? 'previousSibling' : 'nextSibling' ];
  275. }
  276. }
  277. }
  278. // Checks whether the given block can be replaced by a listItem.
  279. //
  280. // @private
  281. // @param {module:engine/model/element~Element} block A block to be tested.
  282. // @param {module:engine/model/schema~Schema} schema The schema of the document.
  283. // @returns {Boolean}
  284. function checkCanBecomeListItem( block, schema ) {
  285. return schema.checkChild( block.parent, 'listItem' ) && !schema.isObject( block );
  286. }