UNPKG

@ckeditor/ckeditor5-list

Version:

Ordered and unordered lists feature to CKEditor 5.

1,246 lines (1,237 loc) • 341 kB
/** * @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