@ckeditor/ckeditor5-list
Version:
Ordered and unordered lists feature to CKEditor 5.
175 lines (174 loc) • 8.09 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
*/
/**
* @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
};
}
}