1 | /**
|
2 | * @license Copyright (c) 2003-2023, 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/documentlist/documentlistmergecommand
|
7 | */
|
8 | import { Command } from 'ckeditor5/src/core';
|
9 | import { getNestedListBlocks, indentBlocks, sortBlocks, isFirstBlockOfListItem, mergeListItemBefore, isSingleListItem, getSelectedBlockObject, isListItemBlock } from './utils/model';
|
10 | import ListWalker from './utils/listwalker';
|
11 | /**
|
12 | * The document list merge command. It is used by the {@link module:list/documentlist~DocumentList list feature}.
|
13 | */
|
14 | export default class DocumentListMergeCommand 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 | }
|