UNPKG

8.09 kBJavaScriptView Raw
1/**
2 * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
3 * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
4 */
5/**
6 * @module list/list/listmergecommand
7 */
8import { Command } from 'ckeditor5/src/core.js';
9import { getNestedListBlocks, indentBlocks, sortBlocks, isFirstBlockOfListItem, mergeListItemBefore, isSingleListItem, getSelectedBlockObject, isListItemBlock } from './utils/model.js';
10import ListWalker from './utils/listwalker.js';
11/**
12 * The document list merge command. It is used by the {@link module:list/list~List list feature}.
13 */
14export default class ListMergeCommand extends Command {
15 /**
16 * Creates an instance of the command.
17 *
18 * @param editor The editor instance.
19 * @param direction Whether list item should be merged before or after the selected block.
20 */
21 constructor(editor, direction) {
22 super(editor);
23 this._direction = direction;
24 }
25 /**
26 * @inheritDoc
27 */
28 refresh() {
29 this.isEnabled = this._checkEnabled();
30 }
31 /**
32 * Merges list blocks together (depending on the {@link #constructor}'s `direction` parameter).
33 *
34 * @fires execute
35 * @fires afterExecute
36 * @param options Command options.
37 * @param options.shouldMergeOnBlocksContentLevel When set `true`, merging will be performed together
38 * with {@link module:engine/model/model~Model#deleteContent} to get rid of the inline content in the selection or take advantage
39 * of the heuristics in `deleteContent()` that helps convert lists into paragraphs in certain cases.
40 */
41 execute({ shouldMergeOnBlocksContentLevel = false } = {}) {
42 const model = this.editor.model;
43 const selection = model.document.selection;
44 const changedBlocks = [];
45 model.change(writer => {
46 const { firstElement, lastElement } = this._getMergeSubjectElements(selection, shouldMergeOnBlocksContentLevel);
47 const firstIndent = firstElement.getAttribute('listIndent') || 0;
48 const lastIndent = lastElement.getAttribute('listIndent');
49 const lastElementId = lastElement.getAttribute('listItemId');
50 if (firstIndent != lastIndent) {
51 const nestedLastElementBlocks = getNestedListBlocks(lastElement);
52 changedBlocks.push(...indentBlocks([lastElement, ...nestedLastElementBlocks], writer, {
53 indentBy: firstIndent - lastIndent,
54 // If outdenting, the entire sub-tree that follows must be included.
55 expand: firstIndent < lastIndent
56 }));
57 }
58 if (shouldMergeOnBlocksContentLevel) {
59 let sel = selection;
60 if (selection.isCollapsed) {
61 sel = writer.createSelection(writer.createRange(writer.createPositionAt(firstElement, 'end'), writer.createPositionAt(lastElement, 0)));
62 }
63 // Delete selected content. Replace entire content only for non-collapsed selection.
64 model.deleteContent(sel, { doNotResetEntireContent: selection.isCollapsed });
65 // Get the last "touched" element after deleteContent call (can't use the lastElement because
66 // it could get merged into the firstElement while deleting content).
67 const lastElementAfterDelete = sel.getLastPosition().parent;
68 // Check if the element after it was in the same list item and adjust it if needed.
69 const nextSibling = lastElementAfterDelete.nextSibling;
70 changedBlocks.push(lastElementAfterDelete);
71 if (nextSibling && nextSibling !== lastElement && nextSibling.getAttribute('listItemId') == lastElementId) {
72 changedBlocks.push(...mergeListItemBefore(nextSibling, lastElementAfterDelete, writer));
73 }
74 }
75 else {
76 changedBlocks.push(...mergeListItemBefore(lastElement, firstElement, writer));
77 }
78 this._fireAfterExecute(changedBlocks);
79 });
80 }
81 /**
82 * Fires the `afterExecute` event.
83 *
84 * @param changedBlocks The changed list elements.
85 */
86 _fireAfterExecute(changedBlocks) {
87 this.fire('afterExecute', sortBlocks(new Set(changedBlocks)));
88 }
89 /**
90 * Checks whether the command can be enabled in the current context.
91 *
92 * @returns Whether the command should be enabled.
93 */
94 _checkEnabled() {
95 const model = this.editor.model;
96 const selection = model.document.selection;
97 const selectedBlockObject = getSelectedBlockObject(model);
98 if (selection.isCollapsed || selectedBlockObject) {
99 const positionParent = selectedBlockObject || selection.getFirstPosition().parent;
100 if (!isListItemBlock(positionParent)) {
101 return false;
102 }
103 const siblingNode = this._direction == 'backward' ?
104 positionParent.previousSibling :
105 positionParent.nextSibling;
106 if (!siblingNode) {
107 return false;
108 }
109 if (isSingleListItem([positionParent, siblingNode])) {
110 return false;
111 }
112 }
113 else {
114 const lastPosition = selection.getLastPosition();
115 const firstPosition = selection.getFirstPosition();
116 // If deleting within a single block of a list item, there's no need to merge anything.
117 // The default delete should be executed instead.
118 if (lastPosition.parent === firstPosition.parent) {
119 return false;
120 }
121 if (!isListItemBlock(lastPosition.parent)) {
122 return false;
123 }
124 }
125 return true;
126 }
127 /**
128 * Returns the boundary elements the merge should be executed for. These are not necessarily selection's first
129 * and last position parents but sometimes sibling or even further blocks depending on the context.
130 *
131 * @param selection The selection the merge is executed for.
132 * @param shouldMergeOnBlocksContentLevel When `true`, merge is performed together with
133 * {@link module:engine/model/model~Model#deleteContent} to remove the inline content within the selection.
134 */
135 _getMergeSubjectElements(selection, shouldMergeOnBlocksContentLevel) {
136 const model = this.editor.model;
137 const selectedBlockObject = getSelectedBlockObject(model);
138 let firstElement, lastElement;
139 if (selection.isCollapsed || selectedBlockObject) {
140 const positionParent = selectedBlockObject || selection.getFirstPosition().parent;
141 const isFirstBlock = isFirstBlockOfListItem(positionParent);
142 if (this._direction == 'backward') {
143 lastElement = positionParent;
144 if (isFirstBlock && !shouldMergeOnBlocksContentLevel) {
145 // For the "c" as an anchorElement:
146 // * a
147 // * b
148 // * [c] <-- this block should be merged with "a"
149 // It should find "a" element to merge with:
150 // * a
151 // * b
152 // c
153 firstElement = ListWalker.first(positionParent, { sameIndent: true, lowerIndent: true });
154 }
155 else {
156 firstElement = positionParent.previousSibling;
157 }
158 }
159 else {
160 // In case of the forward merge there is no case as above, just merge with next sibling.
161 firstElement = positionParent;
162 lastElement = positionParent.nextSibling;
163 }
164 }
165 else {
166 firstElement = selection.getFirstPosition().parent;
167 lastElement = selection.getLastPosition().parent;
168 }
169 return {
170 firstElement: firstElement,
171 lastElement: lastElement
172 };
173 }
174}