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 | */
|
5 | import { Command } from 'ckeditor5/src/core';
|
6 | import { first } from 'ckeditor5/src/utils';
|
7 | /**
|
8 | * The list command. It is used by the {@link module:list/list~List list feature}.
|
9 | */
|
10 | export 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 | */
|
232 | function _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 | */
|
272 | function checkCanBecomeListItem(block, schema) {
|
273 | return schema.checkChild(block.parent, 'listItem') && !schema.isObject(block);
|
274 | }
|