@ckeditor/ckeditor5-list
Version:
Ordered and unordered lists feature to CKEditor 5.
436 lines (435 loc) • 15.5 kB
JavaScript
/**
* @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
*/
import { uid, toArray } from 'ckeditor5/src/utils';
import ListWalker, { iterateSiblingListBlocks } from './listwalker';
/**
* The list item ID generator.
*
* @internal
*/
export class ListItemUid {
/**
* Returns the next ID.
*
* @internal
*/
/* istanbul ignore next: static function definition -- @preserve */
static next() {
return uid();
}
}
/**
* Returns true if the given model node is a list item block.
*
* @internal
*/
export function isListItemBlock(node) {
return !!node && node.is('element') && node.hasAttribute('listItemId');
}
/**
* Returns an array with all elements that represents the same list item.
*
* It means that values for `listIndent`, and `listItemId` for all items are equal.
*
* @internal
* @param listItem Starting list item element.
* @param options.higherIndent Whether blocks with a higher indent level than the start block should be included
* in the result.
*/
export function getAllListItemBlocks(listItem, options = {}) {
return [
...getListItemBlocks(listItem, { ...options, direction: 'backward' }),
...getListItemBlocks(listItem, { ...options, direction: 'forward' })
];
}
/**
* Returns an array with elements that represents the same list item in the specified direction.
*
* It means that values for `listIndent` and `listItemId` for all items are equal.
*
* **Note**: For backward search the provided item is not included, but for forward search it is included in the result.
*
* @internal
* @param listItem Starting list item element.
* @param options.direction Walking direction.
* @param options.higherIndent Whether blocks with a higher indent level than the start block should be included in the result.
*/
export function getListItemBlocks(listItem, options = {}) {
const isForward = options.direction == 'forward';
const items = Array.from(new ListWalker(listItem, {
...options,
includeSelf: isForward,
sameIndent: true,
sameAttributes: 'listItemId'
}));
return isForward ? items : items.reverse();
}
/**
* Returns a list items nested inside the given list item.
*
* @internal
*/
export function getNestedListBlocks(listItem) {
return Array.from(new ListWalker(listItem, {
direction: 'forward',
higherIndent: true
}));
}
/**
* Returns array of all blocks/items of the same list as given block (same indent, same type and properties).
*
* @internal
* @param listItem Starting list item element.
*/
export function getListItems(listItem) {
const backwardBlocks = new ListWalker(listItem, {
sameIndent: true,
sameAttributes: 'listType'
});
const forwardBlocks = new ListWalker(listItem, {
sameIndent: true,
sameAttributes: 'listType',
includeSelf: true,
direction: 'forward'
});
return [
...Array.from(backwardBlocks).reverse(),
...forwardBlocks
];
}
/**
* Check if the given block is the first in the list item.
*
* @internal
* @param listBlock The list block element.
*/
export function isFirstBlockOfListItem(listBlock) {
const previousSibling = ListWalker.first(listBlock, {
sameIndent: true,
sameAttributes: 'listItemId'
});
if (!previousSibling) {
return true;
}
return false;
}
/**
* Check if the given block is the last in the list item.
*
* @internal
*/
export function isLastBlockOfListItem(listBlock) {
const nextSibling = ListWalker.first(listBlock, {
direction: 'forward',
sameIndent: true,
sameAttributes: 'listItemId'
});
if (!nextSibling) {
return true;
}
return false;
}
/**
* Expands the given list of selected blocks to include the leading and tailing blocks of partially selected list items.
*
* @internal
* @param blocks The list of selected blocks.
* @param options.withNested Whether should include nested list items.
*/
export function expandListBlocksToCompleteItems(blocks, options = {}) {
blocks = toArray(blocks);
const higherIndent = options.withNested !== false;
const allBlocks = new Set();
for (const block of blocks) {
for (const itemBlock of getAllListItemBlocks(block, { higherIndent })) {
allBlocks.add(itemBlock);
}
}
return sortBlocks(allBlocks);
}
/**
* Expands the given list of selected blocks to include all the items of the lists they're in.
*
* @internal
* @param blocks The list of selected blocks.
*/
export function expandListBlocksToCompleteList(blocks) {
blocks = toArray(blocks);
const allBlocks = new Set();
for (const block of blocks) {
for (const itemBlock of getListItems(block)) {
allBlocks.add(itemBlock);
}
}
return sortBlocks(allBlocks);
}
/**
* Splits the list item just before the provided list block.
*
* @internal
* @param listBlock The list block element.
* @param writer The model writer.
* @returns The array of updated blocks.
*/
export function splitListItemBefore(listBlock, writer) {
const blocks = getListItemBlocks(listBlock, { direction: 'forward' });
const id = ListItemUid.next();
for (const block of blocks) {
writer.setAttribute('listItemId', id, block);
}
return blocks;
}
/**
* Merges the list item with the parent list item.
*
* @internal
* @param listBlock The list block element.
* @param parentBlock The list block element to merge with.
* @param writer The model writer.
* @returns The array of updated blocks.
*/
export function mergeListItemBefore(listBlock, parentBlock, writer) {
const attributes = {};
for (const [key, value] of parentBlock.getAttributes()) {
if (key.startsWith('list')) {
attributes[key] = value;
}
}
const blocks = getListItemBlocks(listBlock, { direction: 'forward' });
for (const block of blocks) {
writer.setAttributes(attributes, block);
}
return blocks;
}
/**
* Increases indentation of given list blocks.
*
* @internal
* @param blocks The block or iterable of blocks.
* @param writer The model writer.
* @param options.expand Whether should expand the list of blocks to include complete list items.
* @param options.indentBy The number of levels the indentation should change (could be negative).
*/
export function indentBlocks(blocks, writer, { expand, indentBy = 1 } = {}) {
blocks = toArray(blocks);
// Expand the selected blocks to contain the whole list items.
const allBlocks = expand ? expandListBlocksToCompleteItems(blocks) : blocks;
for (const block of allBlocks) {
const blockIndent = block.getAttribute('listIndent') + indentBy;
if (blockIndent < 0) {
removeListAttributes(block, writer);
}
else {
writer.setAttribute('listIndent', blockIndent, block);
}
}
return allBlocks;
}
/**
* Decreases indentation of given list of blocks. If the indentation of some blocks matches the indentation
* of surrounding blocks, they get merged together.
*
* @internal
* @param blocks The block or iterable of blocks.
* @param writer The model writer.
*/
export function outdentBlocksWithMerge(blocks, writer) {
blocks = toArray(blocks);
// Expand the selected blocks to contain the whole list items.
const allBlocks = expandListBlocksToCompleteItems(blocks);
const visited = new Set();
const referenceIndent = Math.min(...allBlocks.map(block => block.getAttribute('listIndent')));
const parentBlocks = new Map();
// Collect parent blocks before the list structure gets altered.
for (const block of allBlocks) {
parentBlocks.set(block, ListWalker.first(block, { lowerIndent: true }));
}
for (const block of allBlocks) {
if (visited.has(block)) {
continue;
}
visited.add(block);
const blockIndent = block.getAttribute('listIndent') - 1;
if (blockIndent < 0) {
removeListAttributes(block, writer);
continue;
}
// Merge with parent list item while outdenting and indent matches reference indent.
if (block.getAttribute('listIndent') == referenceIndent) {
const mergedBlocks = mergeListItemIfNotLast(block, parentBlocks.get(block), writer);
// All list item blocks are updated while merging so add those to visited set.
for (const mergedBlock of mergedBlocks) {
visited.add(mergedBlock);
}
// The indent level was updated while merging so continue to next block.
if (mergedBlocks.length) {
continue;
}
}
writer.setAttribute('listIndent', blockIndent, block);
}
return sortBlocks(visited);
}
/**
* Removes all list attributes from the given blocks.
*
* @internal
* @param blocks The block or iterable of blocks.
* @param writer The model writer.
* @returns Array of altered blocks.
*/
export function removeListAttributes(blocks, writer) {
blocks = toArray(blocks);
for (const block of blocks) {
for (const attributeKey of block.getAttributeKeys()) {
if (attributeKey.startsWith('list')) {
writer.removeAttribute(attributeKey, block);
}
}
}
return blocks;
}
/**
* Checks whether the given blocks are related to a single list item.
*
* @internal
* @param blocks The list block elements.
*/
export function isSingleListItem(blocks) {
if (!blocks.length) {
return false;
}
const firstItemId = blocks[0].getAttribute('listItemId');
if (!firstItemId) {
return false;
}
return !blocks.some(item => item.getAttribute('listItemId') != firstItemId);
}
/**
* Modifies the indents of list blocks following the given list block so the indentation is valid after
* the given block is no longer a list item.
*
* @internal
* @param lastBlock The last list block that has become a non-list element.
* @param writer The model writer.
* @returns Array of altered blocks.
*/
export function outdentFollowingItems(lastBlock, writer) {
const changedBlocks = [];
// Start from the model item that is just after the last turned-off item.
let currentIndent = Number.POSITIVE_INFINITY;
// Correct indent of all items after the last turned off item.
// Rules that should be followed:
// 1. All direct sub-items of turned-off item should become indent 0, because the first item after it
// will be the first item of a new list. Other items are at the same level, so should have same 0 index.
// 2. All items with indent lower than indent of turned-off item should become indent 0, because they
// should not end up as a child of any of list items that they were not children of before.
// 3. All other items should have their indent changed relatively to it's parent.
//
// For example:
// 1 * --------
// 2 * --------
// 3 * -------- <-- this is turned off.
// 4 * -------- <-- this has to become indent = 0, because it will be first item on a new list.
// 5 * -------- <-- this should be still be a child of item above, so indent = 1.
// 6 * -------- <-- this has to become indent = 0, because it should not be a child of any of items above.
// 7 * -------- <-- this should be still be a child of item above, so indent = 1.
// 8 * -------- <-- this has to become indent = 0.
// 9 * -------- <-- this should still be a child of item above, so indent = 1.
// 10 * -------- <-- this should still be a child of item above, so indent = 2.
// 11 * -------- <-- this should still be at the same level as item above, so indent = 2.
// 12 * -------- <-- this and all below are left unchanged.
// 13 * --------
// 14 * --------
//
// After turning off 3 the list becomes:
//
// 1 * --------
// 2 * --------
//
// 3 --------
//
// 4 * --------
// 5 * --------
// 6 * --------
// 7 * --------
// 8 * --------
// 9 * --------
// 10 * --------
// 11 * --------
// 12 * --------
// 13 * --------
// 14 * --------
//
// Thanks to this algorithm no lists are mismatched and no items get unexpected children/parent, while
// those parent-child connection which are possible to maintain are still maintained. It's worth noting
// that this is the same effect that we would be get by multiple use of outdent command. However doing
// it like this is much more efficient because it's less operation (less memory usage, easier OT) and
// less conversion (faster).
for (const { node } of iterateSiblingListBlocks(lastBlock.nextSibling, 'forward')) {
// Check each next list item, as long as its indent is higher than 0.
const indent = node.getAttribute('listIndent');
// If the indent is 0 we are not going to change anything anyway.
if (indent == 0) {
break;
}
// We check if that's item indent is lower than current relative indent.
if (indent < currentIndent) {
// If it is, current relative indent becomes that indent.
currentIndent = indent;
}
// Fix indent relatively to current relative indent.
// Note, that if we just changed the current relative indent, the newIndent will be equal to 0.
const newIndent = indent - currentIndent;
writer.setAttribute('listIndent', newIndent, node);
changedBlocks.push(node);
}
return changedBlocks;
}
/**
* Returns the array of given blocks sorted by model indexes (document order).
*
* @internal
*/
export function sortBlocks(blocks) {
return Array.from(blocks)
.filter(block => block.root.rootName !== '$graveyard')
.sort((a, b) => a.index - b.index);
}
/**
* Returns a selected block object. If a selected object is inline or when there is no selected
* object, `null` is returned.
*
* @internal
* @param model The instance of editor model.
* @returns Selected block object or `null`.
*/
export function getSelectedBlockObject(model) {
const selectedElement = model.document.selection.getSelectedElement();
if (!selectedElement) {
return null;
}
if (model.schema.isObject(selectedElement) && model.schema.isBlock(selectedElement)) {
return selectedElement;
}
return null;
}
// 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.
function mergeListItemIfNotLast(block, parentBlock, writer) {
const parentItemBlocks = getListItemBlocks(parentBlock, { direction: 'forward' });
// Merge with parent only if outdented item wasn't the last one in its parent.
// Merge:
// * a -> * a
// * [b] -> b
// c -> c
// Don't merge:
// * a -> * a
// * [b] -> * b
// * c -> * c
if (parentItemBlocks.pop().index > block.index) {
return mergeListItemBefore(block, parentBlock, writer);
}
return [];
}