UNPKG

@ckeditor/ckeditor5-list

Version:

Ordered and unordered lists feature to CKEditor 5.

275 lines (274 loc) • 15.4 kB
/** * @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); }