-
Notifications
You must be signed in to change notification settings - Fork 3.6k
/
basecommand.ts
249 lines (207 loc) · 8.03 KB
/
basecommand.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
/**
* @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 undo/basecommand
*/
import { Command, type Editor } from '@ckeditor/ckeditor5-core';
import {
transformSets,
type Batch,
type Operation,
type DataControllerSetEvent,
type Range,
NoOperation
} from '@ckeditor/ckeditor5-engine';
/**
* Base class for the undo feature commands: {@link module:undo/undocommand~UndoCommand} and {@link module:undo/redocommand~RedoCommand}.
*/
export default abstract class BaseCommand extends Command {
/**
* Stack of items stored by the command. These are pairs of:
*
* * {@link module:engine/model/batch~Batch batch} saved by the command,
* * {@link module:engine/model/selection~Selection selection} state at the moment of saving the batch.
*/
protected _stack: Array<{ batch: Batch; selection: { ranges: Array<Range>; isBackward: boolean } }> = [];
/**
* Stores all batches that were created by this command.
*
* @internal
*/
public _createdBatches = new WeakSet<Batch>();
/**
* @inheritDoc
*/
constructor( editor: Editor ) {
super( editor );
// Refresh state, so the command is inactive right after initialization.
this.refresh();
// This command should not depend on selection change.
this._isEnabledBasedOnSelection = false;
// Set the transparent batch for the `editor.data.set()` call if the
// batch type is not set already.
this.listenTo<DataControllerSetEvent>( editor.data, 'set', ( evt, data ) => {
// Create a shallow copy of the options to not change the original args.
// And make sure that an object is assigned to data[ 1 ].
data[ 1 ] = { ...data[ 1 ] };
const options = data[ 1 ];
// If batch type is not set, default to non-undoable batch.
if ( !options.batchType ) {
options.batchType = { isUndoable: false };
}
}, { priority: 'high' } );
// Clear the stack for the `transparent` batches.
this.listenTo<DataControllerSetEvent>( editor.data, 'set', ( evt, data ) => {
// We can assume that the object exists and it has a `batchType` property.
// It was ensured with a higher priority listener before.
const options = data[ 1 ]!;
if ( !options.batchType!.isUndoable ) {
this.clearStack();
}
} );
}
/**
* @inheritDoc
*/
public override refresh(): void {
this.isEnabled = this._stack.length > 0;
}
/**
* Returns all batches created by this command.
*/
public get createdBatches(): WeakSet<Batch> {
return this._createdBatches;
}
/**
* Stores a batch in the command, together with the selection state of the {@link module:engine/model/document~Document document}
* created by the editor which this command is registered to.
*
* @param batch The batch to add.
*/
public addBatch( batch: Batch ): void {
const docSelection = this.editor.model.document.selection;
const selection = {
ranges: docSelection.hasOwnRange ? Array.from( docSelection.getRanges() ) : [],
isBackward: docSelection.isBackward
};
this._stack.push( { batch, selection } );
this.refresh();
}
/**
* Removes all items from the stack.
*/
public clearStack(): void {
this._stack = [];
this.refresh();
}
/**
* Restores the {@link module:engine/model/document~Document#selection document selection} state after a batch was undone.
*
* @param ranges Ranges to be restored.
* @param isBackward A flag describing whether the restored range was selected forward or backward.
* @param operations Operations which has been applied since selection has been stored.
*/
protected _restoreSelection(
ranges: Array<Range>,
isBackward: boolean,
operations: Array<Operation>
): void {
const model = this.editor.model;
const document = model.document;
// This will keep the transformed selection ranges.
const selectionRanges: Array<Range> = [];
// Transform all ranges from the restored selection.
const transformedRangeGroups = ranges.map( range => range.getTransformedByOperations( operations ) );
const allRanges = transformedRangeGroups.flat();
for ( const rangeGroup of transformedRangeGroups ) {
// While transforming there could appear ranges that are contained by other ranges, we shall ignore them.
const transformed = rangeGroup
.filter( range => range.root != document.graveyard )
.filter( range => !isRangeContainedByAnyOtherRange( range, allRanges ) );
// All the transformed ranges ended up in graveyard.
if ( !transformed.length ) {
continue;
}
// After the range got transformed, we have an array of ranges. Some of those
// ranges may be "touching" -- they can be next to each other and could be merged.
normalizeRanges( transformed );
// For each `range` from `ranges`, we take only one transformed range.
// This is because we want to prevent situation where single-range selection
// got transformed to multi-range selection.
selectionRanges.push( transformed[ 0 ] );
}
// @if CK_DEBUG_ENGINE // console.log( `Restored selection by undo: ${ selectionRanges.join( ', ' ) }` );
// `selectionRanges` may be empty if all ranges ended up in graveyard. If that is the case, do not restore selection.
if ( selectionRanges.length ) {
model.change( writer => {
writer.setSelection( selectionRanges, { backward: isBackward } );
} );
}
}
/**
* Undoes a batch by reversing that batch, transforming reversed batch and finally applying it.
* This is a helper method for {@link #execute}.
*
* @param batchToUndo The batch to be undone.
* @param undoingBatch The batch that will contain undoing changes.
*/
protected _undo( batchToUndo: Batch, undoingBatch: Batch ): void {
const model = this.editor.model;
const document = model.document;
// All changes done by the command execution will be saved as one batch.
this._createdBatches.add( undoingBatch );
const operationsToUndo = batchToUndo.operations.slice().filter( operation => operation.isDocumentOperation );
operationsToUndo.reverse();
// We will process each operation from `batchToUndo`, in reverse order. If there were operations A, B and C in undone batch,
// we need to revert them in reverse order, so first C' (reversed C), then B', then A'.
for ( const operationToUndo of operationsToUndo ) {
const nextBaseVersion = operationToUndo.baseVersion! + 1;
const historyOperations = Array.from( document.history.getOperations( nextBaseVersion ) );
const transformedSets = transformSets(
[ operationToUndo.getReversed() ],
historyOperations,
{
useRelations: true,
document: this.editor.model.document,
padWithNoOps: false,
forceWeakRemove: true
}
);
const reversedOperations = transformedSets.operationsA;
// After reversed operation has been transformed by all history operations, apply it.
for ( let operation of reversedOperations ) {
// Do not apply any operation on non-editable space.
const affectedSelectable = operation.affectedSelectable;
if ( affectedSelectable && !model.canEditAt( affectedSelectable ) ) {
operation = new NoOperation( operation.baseVersion );
}
// Before applying, add the operation to the `undoingBatch`.
undoingBatch.addOperation( operation );
model.applyOperation( operation );
document.history.setOperationAsUndone( operationToUndo, operation );
}
}
}
}
/**
* Normalizes list of ranges by joining intersecting or "touching" ranges.
*
* @param ranges Ranges to be normalized.
*/
function normalizeRanges( ranges: Array<Range> ): void {
ranges.sort( ( a, b ) => a.start.isBefore( b.start ) ? -1 : 1 );
for ( let i = 1; i < ranges.length; i++ ) {
const previousRange = ranges[ i - 1 ];
const joinedRange = previousRange.getJoined( ranges[ i ], true );
if ( joinedRange ) {
// Replace the ranges on the list with the new joined range.
i--;
ranges.splice( i, 2, joinedRange );
}
}
}
function isRangeContainedByAnyOtherRange( range: Range, ranges: Array<Range> ): boolean {
return ranges.some( otherRange => otherRange !== range && otherRange.containsRange( range, true ) );
}