UNPKG

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