UNPKG

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