UNPKG

15.5 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 { uid, toArray } from 'ckeditor5/src/utils';
6import ListWalker, { iterateSiblingListBlocks } from './listwalker';
7/**
8 * The list item ID generator.
9 *
10 * @internal
11 */
12export 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 */
28export 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 */
41export 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 */
59export 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 */
74export 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 */
86export 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 */
108export 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 */
123export 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 */
141export 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 */
158export 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 */
176export 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 */
193export 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 */
215export 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 */
238export 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 */
283export 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 */
300export 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 */
319export 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 */
396export 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 */
409export 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.
420function 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}