@ckeditor/ckeditor5-list
Version:
Ordered and unordered lists feature to CKEditor 5.
275 lines (274 loc) • 15.4 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 { Command } from 'ckeditor5/src/core';
import { first } from 'ckeditor5/src/utils';
/**
* The list command. It is used by the {@link module:list/list~List list feature}.
*/
export default class ListCommand extends Command {
/**
* 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) {
super(editor);
this.type = type;
}
/**
* @inheritDoc
*/
refresh() {
this.value = this._getValue();
this.isEnabled = this._checkEnabled();
}
/**
* Executes the list command.
*
* @fires execute
* @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.
*/
execute(options = {}) {
const model = this.editor.model;
const document = model.document;
const blocks = Array.from(document.selection.getSelectedBlocks())
.filter(block => checkCanBecomeListItem(block, model.schema));
// Whether we are turning off some items.
const turnOff = options.forceValue !== undefined ? !options.forceValue : this.value;
// If we are turning off items, we are going to rename them to paragraphs.
model.change(writer => {
// If part of a list got turned off, we need to handle (outdent) all of sub-items of the last turned-off item.
// To be sure that model is all the time in a good state, we first fix items below turned-off item.
if (turnOff) {
// Start from the model item that is just after the last turned-off item.
let next = blocks[blocks.length - 1].nextSibling;
let currentIndent = Number.POSITIVE_INFINITY;
let changes = [];
// 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).
while (next && next.name == 'listItem' && next.getAttribute('listIndent') !== 0) {
// Check each next list item, as long as its indent is bigger than 0.
// If the indent is 0 we are not going to change anything anyway.
const indent = next.getAttribute('listIndent');
// We check if that's item indent is lower as 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;
// Save the entry in changes array. We do not apply it at the moment, because we will need to
// reverse the changes so the last item is changed first.
// This is to keep model in correct state all the time.
changes.push({ element: next, listIndent: newIndent });
// Find next item.
next = next.nextSibling;
}
changes = changes.reverse();
for (const item of changes) {
writer.setAttribute('listIndent', item.listIndent, item.element);
}
}
// If we are turning on, we might change some items that are already `listItem`s but with different type.
// Changing one nested list item to other type should also trigger changing all its siblings so the
// whole nested list is of the same type.
// Example (assume changing to numbered list):
// * ------ <-- do not fix, top level item
// * ------ <-- fix, because latter list item of this item's list is changed
// * ------ <-- do not fix, item is not affected (different list)
// * ------ <-- fix, because latter list item of this item's list is changed
// * ------ <-- fix, because latter list item of this item's list is changed
// * ---[-- <-- already in selection
// * ------ <-- already in selection
// * ------ <-- already in selection
// * ------ <-- already in selection, but does not cause other list items to change because is top-level
// * ---]-- <-- already in selection
// * ------ <-- fix, because preceding list item of this item's list is changed
// * ------ <-- do not fix, item is not affected (different list)
// * ------ <-- do not fix, top level item
if (!turnOff) {
// Find lowest indent among selected items. This will be indicator what is the indent of
// top-most list affected by the command.
let lowestIndent = Number.POSITIVE_INFINITY;
for (const item of blocks) {
if (item.is('element', 'listItem') && item.getAttribute('listIndent') < lowestIndent) {
lowestIndent = item.getAttribute('listIndent');
}
}
// Do not execute the fix for top-level lists.
lowestIndent = lowestIndent === 0 ? 1 : lowestIndent;
// Fix types of list items that are "before" the selected blocks.
_fixType(blocks, true, lowestIndent);
// Fix types of list items that are "after" the selected blocks.
_fixType(blocks, false, lowestIndent);
}
// Phew! Now it will be easier :).
// For each block element that was in the selection, we will either: turn it to list item,
// turn it to paragraph, or change it's type. Or leave it as it is.
// Do it in reverse as there might be multiple blocks (same as with changing indents).
for (const element of blocks.reverse()) {
if (turnOff && element.name == 'listItem') {
// We are turning off and the element is a `listItem` - it should be converted to `paragraph`.
// List item specific attributes are removed by post fixer.
writer.rename(element, 'paragraph');
}
else if (!turnOff && element.name != 'listItem') {
// We are turning on and the element is not a `listItem` - it should be converted to `listItem`.
// The order of operations is important to keep model in correct state.
writer.setAttributes({ listType: this.type, listIndent: 0 }, element);
writer.rename(element, 'listItem');
}
else if (!turnOff && element.name == 'listItem' && element.getAttribute('listType') != this.type) {
// We are turning on and the element is a `listItem` but has different type - change it's type and
// type of it's all siblings that have same indent.
writer.setAttribute('listType', this.type, element);
}
}
/**
* Event fired by the {@link #execute} method.
*
* It allows to execute an action after executing the {@link ~ListCommand#execute} method, for example adjusting
* attributes of changed blocks.
*
* @protected
* @event _executeCleanup
*/
this.fire('_executeCleanup', blocks);
});
}
/**
* Checks the command's {@link #value}.
*
* @returns The current value.
*/
_getValue() {
// Check whether closest `listItem` ancestor of the position has a correct type.
const listItem = first(this.editor.model.document.selection.getSelectedBlocks());
return !!listItem && listItem.is('element', 'listItem') && listItem.getAttribute('listType') == this.type;
}
/**
* Checks whether the command can be enabled in the current context.
*
* @returns Whether the command should be enabled.
*/
_checkEnabled() {
// If command value is true it means that we are in list item, so the command should be enabled.
if (this.value) {
return true;
}
const selection = this.editor.model.document.selection;
const schema = this.editor.model.schema;
const firstBlock = first(selection.getSelectedBlocks());
if (!firstBlock) {
return false;
}
// Otherwise, check if list item can be inserted at the position start.
return checkCanBecomeListItem(firstBlock, schema);
}
}
/**
* Helper function used when one or more list item have their type changed. Fixes type of other list items
* that are affected by the change (are in same lists) but are not directly in selection. The function got extracted
* not to duplicated code, as same fix has to be performed before and after selection.
*
* @param blocks Blocks that are in selection.
* @param isBackward Specified whether fix will be applied for blocks before first selected block (`true`)
* or blocks after last selected block (`false`).
* @param lowestIndent Lowest indent among selected blocks.
*/
function _fixType(blocks, isBackward, lowestIndent) {
// We need to check previous sibling of first changed item and next siblings of last changed item.
const startingItem = isBackward ? blocks[0] : blocks[blocks.length - 1];
if (startingItem.is('element', 'listItem')) {
let item = startingItem[isBackward ? 'previousSibling' : 'nextSibling'];
// During processing items, keeps the lowest indent of already processed items.
// This saves us from changing too many items.
// Following example is for going forward as it is easier to read, however same applies to going backward.
// * ------
// * ------
// * --[---
// * ------ <-- `lowestIndent` should be 1
// * --]--- <-- `startingItem`, `currentIndent` = 2, `lowestIndent` == 1
// * ------ <-- should be fixed, `indent` == 2 == `currentIndent`
// * ------ <-- should be fixed, set `currentIndent` to 1, `indent` == 1 == `currentIndent`
// * ------ <-- should not be fixed, item is in different list, `indent` = 2, `indent` != `currentIndent`
// * ------ <-- should be fixed, `indent` == 1 == `currentIndent`
// * ------ <-- break loop (`indent` < `lowestIndent`)
let currentIndent = startingItem.getAttribute('listIndent');
// Look back until a list item with indent lower than reference `lowestIndent`.
// That would be the parent of nested sublist which contains item having `lowestIndent`.
while (item && item.is('element', 'listItem') && item.getAttribute('listIndent') >= lowestIndent) {
if (currentIndent > item.getAttribute('listIndent')) {
currentIndent = item.getAttribute('listIndent');
}
// Found an item that is in the same nested sublist.
if (item.getAttribute('listIndent') == currentIndent) {
// Just add the item to selected blocks like it was selected by the user.
blocks[isBackward ? 'unshift' : 'push'](item);
}
item = item[isBackward ? 'previousSibling' : 'nextSibling'];
}
}
}
/**
* Checks whether the given block can be replaced by a listItem.
*
* @param block A block to be tested.
* @param schema The schema of the document.
*/
function checkCanBecomeListItem(block, schema) {
return schema.checkChild(block.parent, 'listItem') && !schema.isObject(block);
}