@ckeditor/ckeditor5-list
Version:
Ordered and unordered lists feature to CKEditor 5.
1,246 lines (1,237 loc) • 341 kB
JavaScript
/**
* @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
*/
import { Command, Plugin, icons } from '@ckeditor/ckeditor5-core/dist/index.js';
import { Delete } from '@ckeditor/ckeditor5-typing/dist/index.js';
import { Enter } from '@ckeditor/ckeditor5-enter/dist/index.js';
import { first, toArray, uid, CKEditorError, global, FocusTracker, KeystrokeHandler, parseKeystroke, getCode, getLocalizedArrowKeyCodeDirection, createElement, logWarning } from '@ckeditor/ckeditor5-utils/dist/index.js';
import { UpcastWriter, DomEventObserver, Matcher, TreeWalker, getFillerOffset } from '@ckeditor/ckeditor5-engine/dist/index.js';
import { ClipboardPipeline } from '@ckeditor/ckeditor5-clipboard/dist/index.js';
import { ButtonView, MenuBarMenuListItemButtonView, View, addKeyboardHandlingForGrid, CollapsibleView, LabeledFieldView, createLabeledInputNumber, SwitchButtonView, ViewCollection, FocusCycler, createDropdown, SplitButtonView, focusChildOnDropdownOpen, MenuBarMenuView } from '@ckeditor/ckeditor5-ui/dist/index.js';
class ListWalker {
/**
* Performs only first step of iteration and returns the result.
*
* @param startElement The start list item block element.
* @param options.direction The iterating direction.
* @param options.includeSelf Whether start block should be included in the result (if it's matching other criteria).
* @param options.sameAttributes Additional attributes that must be the same for each block.
* @param options.sameIndent Whether blocks with the same indent level as the start block should be included
* in the result.
* @param options.lowerIndent Whether blocks with a lower indent level than the start block should be included
* in the result.
* @param options.higherIndent Whether blocks with a higher indent level than the start block should be included
* in the result.
*/ static first(startElement, options) {
const walker = new this(startElement, options);
const iterator = walker[Symbol.iterator]();
return first(iterator);
}
/**
* Iterable interface.
*/ *[Symbol.iterator]() {
const nestedItems = [];
for (const { node } of iterateSiblingListBlocks(this._getStartNode(), this._isForward ? 'forward' : 'backward')){
const indent = node.getAttribute('listIndent');
// Leaving a nested list.
if (indent < this._referenceIndent) {
// Abort searching blocks.
if (!this._lowerIndent) {
break;
}
// While searching for lower indents, update the reference indent to find another parent in the next step.
this._referenceIndent = indent;
} else if (indent > this._referenceIndent) {
// Ignore nested blocks.
if (!this._higherIndent) {
continue;
}
// Collect nested blocks to verify if they are really nested, or it's a different item.
if (!this._isForward) {
nestedItems.push(node);
continue;
}
} else {
// Ignore same indent block.
if (!this._sameIndent) {
// While looking for nested blocks, stop iterating while encountering first same indent block.
if (this._higherIndent) {
// No more nested blocks so yield nested items.
if (nestedItems.length) {
yield* nestedItems;
nestedItems.length = 0;
}
break;
}
continue;
}
// Abort if item has any additionally specified attribute different.
if (this._sameAttributes.some((attr)=>node.getAttribute(attr) !== this._startElement.getAttribute(attr))) {
break;
}
}
// There is another block for the same list item so the nested items were in the same list item.
if (nestedItems.length) {
yield* nestedItems;
nestedItems.length = 0;
}
yield node;
}
}
/**
* Returns the model element to start iterating.
*/ _getStartNode() {
if (this._includeSelf) {
return this._startElement;
}
return this._isForward ? this._startElement.nextSibling : this._startElement.previousSibling;
}
/**
* Creates a document list iterator.
*
* @param startElement The start list item block element.
* @param options.direction The iterating direction.
* @param options.includeSelf Whether start block should be included in the result (if it's matching other criteria).
* @param options.sameAttributes Additional attributes that must be the same for each block.
* @param options.sameIndent Whether blocks with the same indent level as the start block should be included
* in the result.
* @param options.lowerIndent Whether blocks with a lower indent level than the start block should be included
* in the result.
* @param options.higherIndent Whether blocks with a higher indent level than the start block should be included
* in the result.
*/ constructor(startElement, options){
this._startElement = startElement;
this._referenceIndent = startElement.getAttribute('listIndent');
this._isForward = options.direction == 'forward';
this._includeSelf = !!options.includeSelf;
this._sameAttributes = toArray(options.sameAttributes || []);
this._sameIndent = !!options.sameIndent;
this._lowerIndent = !!options.lowerIndent;
this._higherIndent = !!options.higherIndent;
}
}
/**
* Iterates sibling list blocks starting from the given node.
*
* @internal
* @param node The model node.
* @param direction Iteration direction.
* @returns The object with `node` and `previous` {@link module:engine/model/element~Element blocks}.
*/ function* iterateSiblingListBlocks(node, direction = 'forward') {
const isForward = direction == 'forward';
const previousNodesByIndent = []; // Last seen nodes of lower indented lists.
let previous = null;
while(isListItemBlock(node)){
let previousNodeInList = null; // It's like `previous` but has the same indent as current node.
if (previous) {
const nodeIndent = node.getAttribute('listIndent');
const previousNodeIndent = previous.getAttribute('listIndent');
// Let's find previous node for the same indent.
// We're going to need that when we get back to previous indent.
if (nodeIndent > previousNodeIndent) {
previousNodesByIndent[previousNodeIndent] = previous;
} else if (nodeIndent < previousNodeIndent) {
previousNodeInList = previousNodesByIndent[nodeIndent];
previousNodesByIndent.length = nodeIndent;
} else {
previousNodeInList = previous;
}
}
yield {
node,
previous,
previousNodeInList
};
previous = node;
node = isForward ? node.nextSibling : node.previousSibling;
}
}
/**
* The iterable protocol over the list elements.
*
* @internal
*/ class ListBlocksIterable {
/**
* List blocks iterator.
*
* Iterates over all blocks of a list.
*/ [Symbol.iterator]() {
return iterateSiblingListBlocks(this._listHead, 'forward');
}
/**
* @param listHead The head element of a list.
*/ constructor(listHead){
this._listHead = listHead;
}
}
/**
* The list item ID generator.
*
* @internal
*/ 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
*/ 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.
*/ 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.
*/ 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
*/ 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.
* @param options Additional list walker options to modify the range of returned list items.
*/ function getListItems(listItem, options) {
const backwardBlocks = new ListWalker(listItem, {
sameIndent: true,
sameAttributes: 'listType',
...options
});
const forwardBlocks = new ListWalker(listItem, {
sameIndent: true,
sameAttributes: 'listType',
includeSelf: true,
direction: 'forward',
...options
});
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.
*/ 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
*/ 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.
*/ 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.
*/ 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.
*/ 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.
*/ 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).
*/ 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.
*/ 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.
*/ function removeListAttributes(blocks, writer) {
blocks = toArray(blocks);
// Convert simple list items to plain paragraphs.
for (const block of blocks){
if (block.is('element', 'listItem')) {
writer.rename(block, 'paragraph');
}
}
// Remove list attributes.
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.
*/ 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.
*/ 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
*/ 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`.
*/ 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;
}
/**
* Checks whether the given block can be replaced by a listItem.
*
* Note that this is possible only when multiBlock = false option is set in feature config.
*
* @param block A block to be tested.
* @param schema The schema of the document.
*/ function canBecomeSimpleListItem(block, schema) {
return schema.checkChild(block.parent, 'listItem') && schema.checkChild(block, '$text') && !schema.isObject(block);
}
/**
* Returns true if listType is of type `numbered` or `customNumbered`.
*/ function isNumberedListType(listType) {
return listType == 'numbered' || listType == 'customNumbered';
}
/**
* 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 [];
}
class ListIndentCommand extends Command {
/**
* @inheritDoc
*/ refresh() {
this.isEnabled = this._checkEnabled();
}
/**
* Indents or outdents (depending on the {@link #constructor}'s `indentDirection` parameter) selected list items.
*
* @fires execute
* @fires afterExecute
*/ execute() {
const model = this.editor.model;
const blocks = getSelectedListBlocks(model.document.selection);
model.change((writer)=>{
const changedBlocks = [];
// Handle selection contained in the single list item and starting in the following blocks.
if (isSingleListItem(blocks) && !isFirstBlockOfListItem(blocks[0])) {
// Allow increasing indent of following list item blocks.
if (this._direction == 'forward') {
changedBlocks.push(...indentBlocks(blocks, writer));
}
// For indent make sure that indented blocks have a new ID.
// For outdent just split blocks from the list item (give them a new IDs).
changedBlocks.push(...splitListItemBefore(blocks[0], writer));
} else {
// Now just update the attributes of blocks.
if (this._direction == 'forward') {
changedBlocks.push(...indentBlocks(blocks, writer, {
expand: true
}));
} else {
changedBlocks.push(...outdentBlocksWithMerge(blocks, writer));
}
}
// Align the list item type to match the previous list item (from the same list).
for (const block of changedBlocks){
// This block become a plain block (for example a paragraph).
if (!block.hasAttribute('listType')) {
continue;
}
const previousItemBlock = ListWalker.first(block, {
sameIndent: true
});
if (previousItemBlock) {
writer.setAttribute('listType', previousItemBlock.getAttribute('listType'), block);
}
}
this._fireAfterExecute(changedBlocks);
});
}
/**
* Fires the `afterExecute` event.
*
* @param changedBlocks The changed list elements.
*/ _fireAfterExecute(changedBlocks) {
this.fire('afterExecute', sortBlocks(new Set(changedBlocks)));
}
/**
* Checks whether the command can be enabled in the current context.
*
* @returns Whether the command should be enabled.
*/ _checkEnabled() {
// Check whether any of position's ancestor is a list item.
let blocks = getSelectedListBlocks(this.editor.model.document.selection);
let firstBlock = blocks[0];
// If selection is not in a list item, the command is disabled.
if (!firstBlock) {
return false;
}
// If we are outdenting it is enough to be in list item. Every list item can always be outdented.
if (this._direction == 'backward') {
return true;
}
// A single block of a list item is selected, so it could be indented as a sublist.
if (isSingleListItem(blocks) && !isFirstBlockOfListItem(blocks[0])) {
return true;
}
blocks = expandListBlocksToCompleteItems(blocks);
firstBlock = blocks[0];
// Check if there is any list item before selected items that could become a parent of selected items.
const siblingItem = ListWalker.first(firstBlock, {
sameIndent: true
});
if (!siblingItem) {
return false;
}
if (siblingItem.getAttribute('listType') == firstBlock.getAttribute('listType')) {
return true;
}
return false;
}
/**
* Creates an instance of the command.
*
* @param editor The editor instance.
* @param indentDirection The direction of indent. If it is equal to `backward`, the command
* will outdent a list item.
*/ constructor(editor, indentDirection){
super(editor);
this._direction = indentDirection;
}
}
/**
* Returns an array of selected blocks truncated to the first non list block element.
*/ function getSelectedListBlocks(selection) {
const blocks = Array.from(selection.getSelectedBlocks());
const firstNonListBlockIndex = blocks.findIndex((block)=>!isListItemBlock(block));
if (firstNonListBlockIndex != -1) {
blocks.length = firstNonListBlockIndex;
}
return blocks;
}
class ListCommand extends Command {
/**
* @inheritDoc
*/ refresh() {
this.value = this._getValue();
this.isEnabled = this._checkEnabled();
}
/**
* Executes the list command.
*
* @fires execute
* @fires afterExecute
* @param options Command options.
* @param options.forceValue If set, it will force the command behavior. If `true`, the command will try to convert the
* selected items and potentially the neighbor elements to the proper list items. If set to `false` it will convert selected elements
* to paragraphs. If not set, the command will toggle selected elements to list items or paragraphs, depending on the selection.
* @param options.additionalAttributes Additional attributes that are set for list items when the command is executed.
*/ execute(options = {}) {
const model = this.editor.model;
const document = model.document;
const selectedBlockObject = getSelectedBlockObject(model);
const blocks = Array.from(document.selection.getSelectedBlocks()).filter((block)=>model.schema.checkAttribute(block, 'listType') || canBecomeSimpleListItem(block, model.schema));
// Whether we are turning off some items.
const turnOff = options.forceValue !== undefined ? !options.forceValue : this.value;
model.change((writer)=>{
if (turnOff) {
const lastBlock = blocks[blocks.length - 1];
// Split the first block from the list item.
const itemBlocks = getListItemBlocks(lastBlock, {
direction: 'forward'
});
const changedBlocks = [];
if (itemBlocks.length > 1) {
changedBlocks.push(...splitListItemBefore(itemBlocks[1], writer));
}
// Strip list attributes.
changedBlocks.push(...removeListAttributes(blocks, writer));
// Outdent items following the selected list item.
changedBlocks.push(...outdentFollowingItems(lastBlock, writer));
this._fireAfterExecute(changedBlocks);
} else if ((selectedBlockObject || document.selection.isCollapsed) && isListItemBlock(blocks[0])) {
const changedBlocks = getListItems(selectedBlockObject || blocks[0], this._listWalkerOptions);
for (const block of changedBlocks){
writer.setAttributes({
...options.additionalAttributes,
listType: this.type
}, block);
}
this._fireAfterExecute(changedBlocks);
} else {
const changedBlocks = [];
for (const block of blocks){
// Promote the given block to the list item.
if (!block.hasAttribute('listType')) {
// Rename block to a simple list item if this option is enabled.
if (!block.is('element', 'listItem') && canBecomeSimpleListItem(block, model.schema)) {
writer.rename(block, 'listItem');
}
writer.setAttributes({
...options.additionalAttributes,
listIndent: 0,
listItemId: ListItemUid.next(),
listType: this.type
}, block);
changedBlocks.push(block);
} else {
for (const node of expandListBlocksToCompleteItems(block, {
withNested: false
})){
if (node.getAttribute('listType') != this.type) {
writer.setAttributes({
...options.additionalAttributes,
listType: this.type
}, node);
changedBlocks.push(node);
}
}
}
}
this._fireAfterExecute(changedBlocks);
}
});
}
/**
* Fires the `afterExecute` event.
*
* @param changedBlocks The changed list elements.
*/ _fireAfterExecute(changedBlocks) {
this.fire('afterExecute', sortBlocks(new Set(changedBlocks)));
}
/**
* Checks the command's {@link #value}.
*
* @returns The current value.
*/ _getValue() {
const selection = this.editor.model.document.selection;
const blocks = Array.from(selection.getSelectedBlocks());
if (!blocks.length) {
return false;
}
for (const block of blocks){
if (block.getAttribute('listType') != this.type) {
return false;
}
}
return true;
}
/**
* Checks whether the command can be enabled in the current context.
*
* @returns Whether the command should be enabled.
*/ _checkEnabled() {
const model = this.editor.model;
const schema = model.schema;
const selection = model.document.selection;
const blocks = Array.from(selection.getSelectedBlocks());
if (!blocks.length) {
return false;
}
// If command value is true it means that we are in list item, so the command should be enabled.
if (this.value) {
return true;
}
for (const block of blocks){
if (schema.checkAttribute(block, 'listType') || canBecomeSimpleListItem(block, schema)) {
return true;
}
}
return false;
}
/**
* Creates an instance of the command.
*
* @param editor The editor instance.
* @param type List type that will be handled by this command.
*/ constructor(editor, type, options = {}){
super(editor);
this.type = type;
this._listWalkerOptions = options.multiLevel ? {
higherIndent: true,
lowerIndent: true,
sameAttributes: []
} : undefined;
}
}
class ListMergeCommand extends Command {
/**
* @inheritDoc
*/ refresh() {
this.isEnabled = this._checkEnabled();
}
/**
* Merges list blocks together (depending on the {@link #constructor}'s `direction` parameter).
*
* @fires execute
* @fires afterExecute
* @param options Command options.
* @param options.shouldMergeOnBlocksContentLevel When set `true`, merging will be performed together
* with {@link module:engine/model/model~Model#deleteContent} to get rid of the inline content in the selection or take advantage
* of the heuristics in `deleteContent()` that helps convert lists into paragraphs in certain cases.
*/ execute({ shouldMergeOnBlocksContentLevel = false } = {}) {
const model = this.editor.model;
const selection = model.document.selection;
const changedBlocks = [];
model.change((writer)=>{
const { firstElement, lastElement } = this._getMergeSubjectElements(selection, shouldMergeOnBlocksContentLevel);
const firstIndent = firstElement.getAttribute('listIndent') || 0;
const lastIndent = lastElement.getAttribute('listIndent');
const lastElementId = lastElement.getAttribute('listItemId');
if (firstIndent != lastIndent) {
const nestedLastElementBlocks = getNestedListBlocks(lastElement);
changedBlocks.push(...indentBlocks([
lastElement,
...nestedLastElementBlocks
], writer, {
indentBy: firstIndent - lastIndent,
// If outdenting, the entire sub-tree that follows must be included.
expand: firstIndent < lastIndent
}));
}
if (shouldMergeOnBlocksContentLevel) {
let sel = selection;
if (selection.isCollapsed) {
sel = writer.createSelection(writer.createRange(writer.createPositionAt(firstElement, 'end'), writer.createPositionAt(lastElement, 0)));
}
// Delete selected content. Replace entire content only for non-collapsed selection.
model.deleteContent(sel, {
doNotResetEntireContent: selection.isCollapsed
});
// Get the last "touched" element after deleteContent call (can't use the lastElement because
// it could get merged into the firstElement while deleting content).
const lastElementAfterDelete = sel.getLastPosition().parent;
// Check if the element after it was in the same list item and adjust it if needed.
const nextSibling = lastElementAfterDelete.nextSibling;
changedBlocks.push(lastElementAfterDelete);
if (nextSibling && nextSibling !== lastElement && nextSibling.getAttribute('listItemId') == lastElementId) {
changedBlocks.push(...mergeListItemBefore(nextSibling, lastElementAfterDelete, writer));
}
} else {
changedBlocks.push(...mergeListItemBefore(lastElement, firstElement, writer));
}
this._fireAfterExecute(changedBlocks);
});
}
/**
* Fires the `afterExecute` event.
*
* @param changedBlocks The changed list elements.
*/ _fireAfterExecute(changedBlocks) {
this.fire('afterExecute', sortBlocks(new Set(changedBlocks)));
}
/**
* Checks whether the command can be enabled in the current context.
*
* @returns Whether the command should be enabled.
*/ _checkEnabled() {
const model = this.editor.model;
const selection = model.document.selection;
const selectedBlockObject = getSelectedBlockObject(model);
if (selection.isCollapsed || selectedBlockObject) {
const positionParent = selectedBlockObject || selection.getFirstPosition().parent;
if (!isListItemBlock(positionParent)) {
return false;
}
const siblingNode = this._direction == 'backward' ? positionParent.previousSibling : positionParent.nextSibling;
if (!siblingNode) {
return false;
}
if (isSingleListItem([
positionParent,
siblingNode
])) {
return false;
}
} else {
const lastPosition = selection.getLastPosition();
const firstPosition = selection.getFirstPosition();
// If deleting within a single block of a list item, there's no need to merge anything.
// The default delete should be executed instead.
if (lastPosition.parent === firstPosition.parent) {
return false;
}
if (!isListItemBlock(lastPosition.parent)) {
return false;
}
}
return true;
}
/**
* Returns the boundary elements the merge should be executed for. These are not necessarily selection's first
* and last position parents but sometimes sibling or even further blocks depending on the context.
*
* @param selection The selection the merge is executed for.
* @param shouldMergeOnBlocksContentLevel When `true`, merge is performed together with
* {@link module:engine/model/model~Model#deleteContent} to remove the inline content within the selection.
*/ _getMergeSubjectElements(selection, shouldMergeOnBlocksContentLevel) {
const model = this.editor.model;
const selectedBlockObject = getSelectedBlockObject(model);
let firstElement, lastElement;
if (selection.isCollapsed || selectedBlockObject) {
const positionParent = selectedBlockObject || selection.getFirstPosition().parent;
const isFirstBlock = isFirstBlockOfListItem(positionParent);
if (this._direction == 'backward') {
lastElement = positionParent;
if (isFirstBlock && !shouldMergeOnBlocksContentLevel) {
// For the "c" as an anchorElement:
// * a
// * b
// * [c] <-- this block should be merged with "a"
// It should find "a" element to merge with:
// * a
// * b
// c
firstElement = ListWalker.first(positionParent, {
sameIndent: true,
lowerIndent: true
});
} else {
firstElement = positionParent.previousSibling;
}
} else {
// In case of the forward merge there is no case as above, just merge with next sibling.
firstElement = positionParent;
lastElement = positionParent.nextSibling;
}
} else {
firstElement = selection.getFirstPosition().parent;
lastElement = selection.getLastPosition().parent;
}
return {
firstElement: firstElement,
lastElement: lastElement
};
}
/**
* Creates an instance of the command.
*
* @param editor The editor instance.
* @param direction Whether list item should be merged before or after the selected block.
*/ constructor(editor, direction){
super(editor);
this._direction = direction;
}
}
class ListSplitCommand extends Command {
/**
* @inheritDoc
*/ refresh() {
this.isEnabled = this._checkEnabled();
}
/**
* Splits the list item at the selection.
*
* @fires execute
* @fires afterExecute
*/ execute() {
const editor = this.editor;
editor.model.change((writer)=>{
const changedBlocks = splitListItemBefore(this._getStartBlock(), writer);
this._fireAfterExecute(changedBlocks);
});
}
/**
* Fires the `afterExecute` event.
*
* @param changedBlocks The changed list elements.
*/ _fireAfterExecute(changedBlocks) {
this.fire('afterExecute', sortBlocks(new Set(changedBlocks)));
}
/**
* Checks whether the command can be enabled in the current context.
*
* @returns Whether the command should be enabled.
*/ _checkEnabled() {
const selection = this.editor.model.document.selection;
const block = this._getStartBlock();
return selection.isCollapsed && isListItemBlock(block) && !isFirstBlockOfListItem(block);
}
/**
* Returns the model element that is the main focus of the command (according to the current selection and command direction).
*/ _getStartBlock() {
const doc = this.editor.model.document;
const positionParent = doc.selection.getFirstPosition().parent;
return this._direction == 'before' ? positionParent : positionParent.nextSibling;
}
/**
* Creates an instance of the command.
*
* @param editor The editor instance.
* @param direction Whether list item should be split before or after the selected block.
*/ constructor(editor, direction){
super(editor);
this._direction = direction;
}
}
class ListUtils extends Plugin {
/**
* @inheritDoc
*/ static get pluginName() {
return 'ListUtils';
}
/**
* Expands the given list of selected blocks to include all the items of the lists they're in.
*
* @param blocks The list of selected blocks.
*/ expandListBlocksToCompleteList(blocks) {
return expandListBlocksToCompleteList(blocks);
}
/**
* Check if the given block is the first in the list item.
*
* @param listBlock The list block element.
*/ isFirstBlockOfListItem(listBlock) {
return isFirstBlockOfListItem(listBlock);
}
/**
* Returns true if the given model node is a list item block.
*
* @param node A model node.
*/ isListItemBlock(node) {
return isListItemBlock(node);
}
/**
* Expands the given list of selected blocks to include the leading and tailing blocks of partially selected list items.
*
* @param blocks The list of selected blocks.
* @param options.withNested Whether should include nested list items.
*/ expandListBlocksToCompleteItems(blocks, options = {}) {
return expandListBlocksToCompleteItems(blocks, options);
}
/**
* Returns true if listType is of type `numbered` or `customNumbered`.
*/ isNumberedListType(listType) {
return isNumberedListType(listType);
}
}
/**
* @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
*/ /**
* Checks if view element is a list type (ul or ol).
*
* @internal
*/ function isListView(viewElement) {
return viewElement.is('element', 'ol') || viewElement.is('element', 'ul');
}
/**
* Checks if view element is a list item (li).
*
* @internal
*/ function isListItemView(viewElement) {
return viewElement.is('element', 'li');
}
/**
* Calculates the indent value for a list item. Handles HTML compliant and non-compliant lists.
*
* Also, fixes non HTML compliant lists indents:
*
* ```
* before: fixed list:
* OL OL
* |-> LI (parent LIs: 0) |-> LI (indent: 0)
* |-> OL |-> OL
* |-> OL |
* | |-> OL |
* | |-> OL |
* | |-> LI (parent LIs: 1) |-> LI (indent: 1)
* |-> LI (parent LIs: 1) |-> LI (indent: 1)
*
* before: fixed list:
* OL OL
* |-> OL |
* |-> OL |
* |-> OL |
* |-> LI (parent LIs: 0) |-> LI (indent: 0)
*
* before: fixed list:
* OL OL
* |-> LI (parent LIs: 0) |-> LI (indent: 0)
* |-> OL |-> OL
* |-> LI (parent LIs: 0) |-> LI (indent: 1)
* ```
*
* @internal
*/ function getIndent$1(listItem) {
let indent = 0;
let parent = listItem.parent;
while(parent){
// Each LI in the tree will result in an increased indent for HTML compliant lists.
if (isListItemView(parent)) {
indent++;
} else {
// If however the list is nested in other list we should check previous sibling of any of the list elements...
const previousSibling = parent.previousSibling;
// ...because the we might need increase its indent:
// before: fixed list:
// OL OL
// |-> LI (parent LIs: 0) |-> LI (indent: 0)
// |-> OL |-> OL
// |-> LI (parent LIs: 0) |-> LI (indent: 1)
if (previousSibling && isListItemView(previousSibling)) {
indent++;
}
}
parent = parent.parent;
}
return indent;
}
/**
* Creates a list attribute element (ol or ul).
*
* @internal
*/ function createListElement(writer, indent, type, id = getViewElementIdForListType(type, indent)) {
// Negative priorities so that restricted editing attribute won't wrap lists.
return writer.createAttributeElement(getViewElementNameForListType(type), null, {
priority: 2 * indent / 100 - 100,
id
});
}
/**
* Creates a list item attribute element (li).
*
* @internal
*/ function createListItemElement(writer, indent, id) {
// Negative priorities so that restricted editing attribute won't wrap list items.
return writer.createAttributeElement('li', null, {
priority: (2 * indent + 1) / 100 - 100,
id
});
}
/**
* Returns a view element name for the given list type.
*
* @internal
*/ function getViewElementNameForListType(type) {
return type == 'numbered' || type == 'customNumbered' ? 'ol' : 'ul';
}
/**
* Returns a view element ID for the given list type and indent.
*
* @internal
*/ function getViewElementIdForListType(type, indent) {
return `list-${type}-${indent}`;
}
/**
* Based on the provided positions looks for the list head and stores it in the provided map.
*
* @internal
* @param position The search starting position.
* @param itemToListHead T