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 { uid, toArray } from 'ckeditor5/src/utils';
|
6 | import ListWalker, { iterateSiblingListBlocks } from './listwalker';
|
7 | /**
|
8 | * The list item ID generator.
|
9 | *
|
10 | * @internal
|
11 | */
|
12 | export class ListItemUid {
|
13 | /**
|
14 | * Returns the next ID.
|
15 | *
|
16 | * @internal
|
17 | */
|
18 | /* istanbul ignore next: static function definition -- @preserve */
|
19 | static next() {
|
20 | return uid();
|
21 | }
|
22 | }
|
23 | /**
|
24 | * Returns true if the given model node is a list item block.
|
25 | *
|
26 | * @internal
|
27 | */
|
28 | export function isListItemBlock(node) {
|
29 | return !!node && node.is('element') && node.hasAttribute('listItemId');
|
30 | }
|
31 | /**
|
32 | * Returns an array with all elements that represents the same list item.
|
33 | *
|
34 | * It means that values for `listIndent`, and `listItemId` for all items are equal.
|
35 | *
|
36 | * @internal
|
37 | * @param listItem Starting list item element.
|
38 | * @param options.higherIndent Whether blocks with a higher indent level than the start block should be included
|
39 | * in the result.
|
40 | */
|
41 | export function getAllListItemBlocks(listItem, options = {}) {
|
42 | return [
|
43 | ...getListItemBlocks(listItem, { ...options, direction: 'backward' }),
|
44 | ...getListItemBlocks(listItem, { ...options, direction: 'forward' })
|
45 | ];
|
46 | }
|
47 | /**
|
48 | * Returns an array with elements that represents the same list item in the specified direction.
|
49 | *
|
50 | * It means that values for `listIndent` and `listItemId` for all items are equal.
|
51 | *
|
52 | * **Note**: For backward search the provided item is not included, but for forward search it is included in the result.
|
53 | *
|
54 | * @internal
|
55 | * @param listItem Starting list item element.
|
56 | * @param options.direction Walking direction.
|
57 | * @param options.higherIndent Whether blocks with a higher indent level than the start block should be included in the result.
|
58 | */
|
59 | export function getListItemBlocks(listItem, options = {}) {
|
60 | const isForward = options.direction == 'forward';
|
61 | const items = Array.from(new ListWalker(listItem, {
|
62 | ...options,
|
63 | includeSelf: isForward,
|
64 | sameIndent: true,
|
65 | sameAttributes: 'listItemId'
|
66 | }));
|
67 | return isForward ? items : items.reverse();
|
68 | }
|
69 | /**
|
70 | * Returns a list items nested inside the given list item.
|
71 | *
|
72 | * @internal
|
73 | */
|
74 | export function getNestedListBlocks(listItem) {
|
75 | return Array.from(new ListWalker(listItem, {
|
76 | direction: 'forward',
|
77 | higherIndent: true
|
78 | }));
|
79 | }
|
80 | /**
|
81 | * Returns array of all blocks/items of the same list as given block (same indent, same type and properties).
|
82 | *
|
83 | * @internal
|
84 | * @param listItem Starting list item element.
|
85 | */
|
86 | export function getListItems(listItem) {
|
87 | const backwardBlocks = new ListWalker(listItem, {
|
88 | sameIndent: true,
|
89 | sameAttributes: 'listType'
|
90 | });
|
91 | const forwardBlocks = new ListWalker(listItem, {
|
92 | sameIndent: true,
|
93 | sameAttributes: 'listType',
|
94 | includeSelf: true,
|
95 | direction: 'forward'
|
96 | });
|
97 | return [
|
98 | ...Array.from(backwardBlocks).reverse(),
|
99 | ...forwardBlocks
|
100 | ];
|
101 | }
|
102 | /**
|
103 | * Check if the given block is the first in the list item.
|
104 | *
|
105 | * @internal
|
106 | * @param listBlock The list block element.
|
107 | */
|
108 | export function isFirstBlockOfListItem(listBlock) {
|
109 | const previousSibling = ListWalker.first(listBlock, {
|
110 | sameIndent: true,
|
111 | sameAttributes: 'listItemId'
|
112 | });
|
113 | if (!previousSibling) {
|
114 | return true;
|
115 | }
|
116 | return false;
|
117 | }
|
118 | /**
|
119 | * Check if the given block is the last in the list item.
|
120 | *
|
121 | * @internal
|
122 | */
|
123 | export function isLastBlockOfListItem(listBlock) {
|
124 | const nextSibling = ListWalker.first(listBlock, {
|
125 | direction: 'forward',
|
126 | sameIndent: true,
|
127 | sameAttributes: 'listItemId'
|
128 | });
|
129 | if (!nextSibling) {
|
130 | return true;
|
131 | }
|
132 | return false;
|
133 | }
|
134 | /**
|
135 | * Expands the given list of selected blocks to include the leading and tailing blocks of partially selected list items.
|
136 | *
|
137 | * @internal
|
138 | * @param blocks The list of selected blocks.
|
139 | * @param options.withNested Whether should include nested list items.
|
140 | */
|
141 | export function expandListBlocksToCompleteItems(blocks, options = {}) {
|
142 | blocks = toArray(blocks);
|
143 | const higherIndent = options.withNested !== false;
|
144 | const allBlocks = new Set();
|
145 | for (const block of blocks) {
|
146 | for (const itemBlock of getAllListItemBlocks(block, { higherIndent })) {
|
147 | allBlocks.add(itemBlock);
|
148 | }
|
149 | }
|
150 | return sortBlocks(allBlocks);
|
151 | }
|
152 | /**
|
153 | * Expands the given list of selected blocks to include all the items of the lists they're in.
|
154 | *
|
155 | * @internal
|
156 | * @param blocks The list of selected blocks.
|
157 | */
|
158 | export function expandListBlocksToCompleteList(blocks) {
|
159 | blocks = toArray(blocks);
|
160 | const allBlocks = new Set();
|
161 | for (const block of blocks) {
|
162 | for (const itemBlock of getListItems(block)) {
|
163 | allBlocks.add(itemBlock);
|
164 | }
|
165 | }
|
166 | return sortBlocks(allBlocks);
|
167 | }
|
168 | /**
|
169 | * Splits the list item just before the provided list block.
|
170 | *
|
171 | * @internal
|
172 | * @param listBlock The list block element.
|
173 | * @param writer The model writer.
|
174 | * @returns The array of updated blocks.
|
175 | */
|
176 | export function splitListItemBefore(listBlock, writer) {
|
177 | const blocks = getListItemBlocks(listBlock, { direction: 'forward' });
|
178 | const id = ListItemUid.next();
|
179 | for (const block of blocks) {
|
180 | writer.setAttribute('listItemId', id, block);
|
181 | }
|
182 | return blocks;
|
183 | }
|
184 | /**
|
185 | * Merges the list item with the parent list item.
|
186 | *
|
187 | * @internal
|
188 | * @param listBlock The list block element.
|
189 | * @param parentBlock The list block element to merge with.
|
190 | * @param writer The model writer.
|
191 | * @returns The array of updated blocks.
|
192 | */
|
193 | export function mergeListItemBefore(listBlock, parentBlock, writer) {
|
194 | const attributes = {};
|
195 | for (const [key, value] of parentBlock.getAttributes()) {
|
196 | if (key.startsWith('list')) {
|
197 | attributes[key] = value;
|
198 | }
|
199 | }
|
200 | const blocks = getListItemBlocks(listBlock, { direction: 'forward' });
|
201 | for (const block of blocks) {
|
202 | writer.setAttributes(attributes, block);
|
203 | }
|
204 | return blocks;
|
205 | }
|
206 | /**
|
207 | * Increases indentation of given list blocks.
|
208 | *
|
209 | * @internal
|
210 | * @param blocks The block or iterable of blocks.
|
211 | * @param writer The model writer.
|
212 | * @param options.expand Whether should expand the list of blocks to include complete list items.
|
213 | * @param options.indentBy The number of levels the indentation should change (could be negative).
|
214 | */
|
215 | export function indentBlocks(blocks, writer, { expand, indentBy = 1 } = {}) {
|
216 | blocks = toArray(blocks);
|
217 | // Expand the selected blocks to contain the whole list items.
|
218 | const allBlocks = expand ? expandListBlocksToCompleteItems(blocks) : blocks;
|
219 | for (const block of allBlocks) {
|
220 | const blockIndent = block.getAttribute('listIndent') + indentBy;
|
221 | if (blockIndent < 0) {
|
222 | removeListAttributes(block, writer);
|
223 | }
|
224 | else {
|
225 | writer.setAttribute('listIndent', blockIndent, block);
|
226 | }
|
227 | }
|
228 | return allBlocks;
|
229 | }
|
230 | /**
|
231 | * Decreases indentation of given list of blocks. If the indentation of some blocks matches the indentation
|
232 | * of surrounding blocks, they get merged together.
|
233 | *
|
234 | * @internal
|
235 | * @param blocks The block or iterable of blocks.
|
236 | * @param writer The model writer.
|
237 | */
|
238 | export function outdentBlocksWithMerge(blocks, writer) {
|
239 | blocks = toArray(blocks);
|
240 | // Expand the selected blocks to contain the whole list items.
|
241 | const allBlocks = expandListBlocksToCompleteItems(blocks);
|
242 | const visited = new Set();
|
243 | const referenceIndent = Math.min(...allBlocks.map(block => block.getAttribute('listIndent')));
|
244 | const parentBlocks = new Map();
|
245 | // Collect parent blocks before the list structure gets altered.
|
246 | for (const block of allBlocks) {
|
247 | parentBlocks.set(block, ListWalker.first(block, { lowerIndent: true }));
|
248 | }
|
249 | for (const block of allBlocks) {
|
250 | if (visited.has(block)) {
|
251 | continue;
|
252 | }
|
253 | visited.add(block);
|
254 | const blockIndent = block.getAttribute('listIndent') - 1;
|
255 | if (blockIndent < 0) {
|
256 | removeListAttributes(block, writer);
|
257 | continue;
|
258 | }
|
259 | // Merge with parent list item while outdenting and indent matches reference indent.
|
260 | if (block.getAttribute('listIndent') == referenceIndent) {
|
261 | const mergedBlocks = mergeListItemIfNotLast(block, parentBlocks.get(block), writer);
|
262 | // All list item blocks are updated while merging so add those to visited set.
|
263 | for (const mergedBlock of mergedBlocks) {
|
264 | visited.add(mergedBlock);
|
265 | }
|
266 | // The indent level was updated while merging so continue to next block.
|
267 | if (mergedBlocks.length) {
|
268 | continue;
|
269 | }
|
270 | }
|
271 | writer.setAttribute('listIndent', blockIndent, block);
|
272 | }
|
273 | return sortBlocks(visited);
|
274 | }
|
275 | /**
|
276 | * Removes all list attributes from the given blocks.
|
277 | *
|
278 | * @internal
|
279 | * @param blocks The block or iterable of blocks.
|
280 | * @param writer The model writer.
|
281 | * @returns Array of altered blocks.
|
282 | */
|
283 | export function removeListAttributes(blocks, writer) {
|
284 | blocks = toArray(blocks);
|
285 | for (const block of blocks) {
|
286 | for (const attributeKey of block.getAttributeKeys()) {
|
287 | if (attributeKey.startsWith('list')) {
|
288 | writer.removeAttribute(attributeKey, block);
|
289 | }
|
290 | }
|
291 | }
|
292 | return blocks;
|
293 | }
|
294 | /**
|
295 | * Checks whether the given blocks are related to a single list item.
|
296 | *
|
297 | * @internal
|
298 | * @param blocks The list block elements.
|
299 | */
|
300 | export function isSingleListItem(blocks) {
|
301 | if (!blocks.length) {
|
302 | return false;
|
303 | }
|
304 | const firstItemId = blocks[0].getAttribute('listItemId');
|
305 | if (!firstItemId) {
|
306 | return false;
|
307 | }
|
308 | return !blocks.some(item => item.getAttribute('listItemId') != firstItemId);
|
309 | }
|
310 | /**
|
311 | * Modifies the indents of list blocks following the given list block so the indentation is valid after
|
312 | * the given block is no longer a list item.
|
313 | *
|
314 | * @internal
|
315 | * @param lastBlock The last list block that has become a non-list element.
|
316 | * @param writer The model writer.
|
317 | * @returns Array of altered blocks.
|
318 | */
|
319 | export function outdentFollowingItems(lastBlock, writer) {
|
320 | const changedBlocks = [];
|
321 | // Start from the model item that is just after the last turned-off item.
|
322 | let currentIndent = Number.POSITIVE_INFINITY;
|
323 | // Correct indent of all items after the last turned off item.
|
324 | // Rules that should be followed:
|
325 | // 1. All direct sub-items of turned-off item should become indent 0, because the first item after it
|
326 | // will be the first item of a new list. Other items are at the same level, so should have same 0 index.
|
327 | // 2. All items with indent lower than indent of turned-off item should become indent 0, because they
|
328 | // should not end up as a child of any of list items that they were not children of before.
|
329 | // 3. All other items should have their indent changed relatively to it's parent.
|
330 | //
|
331 | // For example:
|
332 | // 1 * --------
|
333 | // 2 * --------
|
334 | // 3 * -------- <-- this is turned off.
|
335 | // 4 * -------- <-- this has to become indent = 0, because it will be first item on a new list.
|
336 | // 5 * -------- <-- this should be still be a child of item above, so indent = 1.
|
337 | // 6 * -------- <-- this has to become indent = 0, because it should not be a child of any of items above.
|
338 | // 7 * -------- <-- this should be still be a child of item above, so indent = 1.
|
339 | // 8 * -------- <-- this has to become indent = 0.
|
340 | // 9 * -------- <-- this should still be a child of item above, so indent = 1.
|
341 | // 10 * -------- <-- this should still be a child of item above, so indent = 2.
|
342 | // 11 * -------- <-- this should still be at the same level as item above, so indent = 2.
|
343 | // 12 * -------- <-- this and all below are left unchanged.
|
344 | // 13 * --------
|
345 | // 14 * --------
|
346 | //
|
347 | // After turning off 3 the list becomes:
|
348 | //
|
349 | // 1 * --------
|
350 | // 2 * --------
|
351 | //
|
352 | // 3 --------
|
353 | //
|
354 | // 4 * --------
|
355 | // 5 * --------
|
356 | // 6 * --------
|
357 | // 7 * --------
|
358 | // 8 * --------
|
359 | // 9 * --------
|
360 | // 10 * --------
|
361 | // 11 * --------
|
362 | // 12 * --------
|
363 | // 13 * --------
|
364 | // 14 * --------
|
365 | //
|
366 | // Thanks to this algorithm no lists are mismatched and no items get unexpected children/parent, while
|
367 | // those parent-child connection which are possible to maintain are still maintained. It's worth noting
|
368 | // that this is the same effect that we would be get by multiple use of outdent command. However doing
|
369 | // it like this is much more efficient because it's less operation (less memory usage, easier OT) and
|
370 | // less conversion (faster).
|
371 | for (const { node } of iterateSiblingListBlocks(lastBlock.nextSibling, 'forward')) {
|
372 | // Check each next list item, as long as its indent is higher than 0.
|
373 | const indent = node.getAttribute('listIndent');
|
374 | // If the indent is 0 we are not going to change anything anyway.
|
375 | if (indent == 0) {
|
376 | break;
|
377 | }
|
378 | // We check if that's item indent is lower than current relative indent.
|
379 | if (indent < currentIndent) {
|
380 | // If it is, current relative indent becomes that indent.
|
381 | currentIndent = indent;
|
382 | }
|
383 | // Fix indent relatively to current relative indent.
|
384 | // Note, that if we just changed the current relative indent, the newIndent will be equal to 0.
|
385 | const newIndent = indent - currentIndent;
|
386 | writer.setAttribute('listIndent', newIndent, node);
|
387 | changedBlocks.push(node);
|
388 | }
|
389 | return changedBlocks;
|
390 | }
|
391 | /**
|
392 | * Returns the array of given blocks sorted by model indexes (document order).
|
393 | *
|
394 | * @internal
|
395 | */
|
396 | export function sortBlocks(blocks) {
|
397 | return Array.from(blocks)
|
398 | .filter(block => block.root.rootName !== '$graveyard')
|
399 | .sort((a, b) => a.index - b.index);
|
400 | }
|
401 | /**
|
402 | * Returns a selected block object. If a selected object is inline or when there is no selected
|
403 | * object, `null` is returned.
|
404 | *
|
405 | * @internal
|
406 | * @param model The instance of editor model.
|
407 | * @returns Selected block object or `null`.
|
408 | */
|
409 | export function getSelectedBlockObject(model) {
|
410 | const selectedElement = model.document.selection.getSelectedElement();
|
411 | if (!selectedElement) {
|
412 | return null;
|
413 | }
|
414 | if (model.schema.isObject(selectedElement) && model.schema.isBlock(selectedElement)) {
|
415 | return selectedElement;
|
416 | }
|
417 | return null;
|
418 | }
|
419 | // Merges a given block to the given parent block if parent is a list item and there is no more blocks in the same item.
|
420 | function mergeListItemIfNotLast(block, parentBlock, writer) {
|
421 | const parentItemBlocks = getListItemBlocks(parentBlock, { direction: 'forward' });
|
422 | // Merge with parent only if outdented item wasn't the last one in its parent.
|
423 | // Merge:
|
424 | // * a -> * a
|
425 | // * [b] -> b
|
426 | // c -> c
|
427 | // Don't merge:
|
428 | // * a -> * a
|
429 | // * [b] -> * b
|
430 | // * c -> * c
|
431 | if (parentItemBlocks.pop().index > block.index) {
|
432 | return mergeListItemBefore(block, parentBlock, writer);
|
433 | }
|
434 | return [];
|
435 | }
|