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 |
|
10 | import { Command } from 'ckeditor5/src/core';
|
11 | import { 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 | */
|
18 | export 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.
|
280 | function _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}
|
325 | function checkCanBecomeListItem( block, schema ) {
|
326 | return schema.checkChild( block.parent, 'listItem' ) && !schema.isObject( block );
|
327 | }
|