UNPKG

@ckeditor/ckeditor5-list

Version:

Ordered and unordered lists feature to CKEditor 5.

175 lines (174 loc) 8.09 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 */ /** * @module list/list/listmergecommand */ import { Command } from 'ckeditor5/src/core.js'; import { getNestedListBlocks, indentBlocks, sortBlocks, isFirstBlockOfListItem, mergeListItemBefore, isSingleListItem, getSelectedBlockObject, isListItemBlock } from './utils/model.js'; import ListWalker from './utils/listwalker.js'; /** * The document list merge command. It is used by the {@link module:list/list~List list feature}. */ export default class ListMergeCommand extends Command { /** * 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; } /** * @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 }; } }