Skip to content

Commit a47fd09

Browse files
e11syЕгор Коноваловgohabereg
authored
imp(): implement all operations transformations (#115)
* imp(): implement all operations transformations * feat(collab): add operations transformer class * feat(collaboration-manager): determine intersection type util * chore(): renaming * imp(collaboration-manager): operationsTransformer improved * imp(batchedOp): improvements * test(collaboration-manager): add tests for transformations * chore(collaboration-manager): clean up * test(batchedOp): fix test indexes * fix (OT-server): tests * chore(): lint fix * test: recover operation tests * imp(UndoRedoManager): stacks transformation logic improved * feat: add debounce clear batch timer * fix: lint * imp(): tests improved, lint fix * imp(): changes after review * imp(): lint fix * fix(): tests and logic minor improvements * imp(): lint fix * Update remote user's caret instead of a current one * imp(): transform insert against insert payload * imp(): testcases improved * imp(): typo fixed --------- Co-authored-by: Егор Коновалов <egorkonovalov@NB060201N01P.local> Co-authored-by: gohabereg <gohabereg@gmail.com>
1 parent 35f748a commit a47fd09

17 files changed

Lines changed: 2016 additions & 570 deletions
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import type { Index } from '@editorjs/model';
2+
import { createDataKey, IndexBuilder, type TextRange } from '@editorjs/model';
3+
import { BatchedOperation } from './BatchedOperation.js';
4+
import type { SerializedOperation } from './Operation.js';
5+
import { Operation, OperationType } from './Operation.js';
6+
7+
const createIndexByRange = (range: TextRange): Index => new IndexBuilder()
8+
.addBlockIndex(0)
9+
.addDataKey(createDataKey('key'))
10+
.addTextRange(range)
11+
.build();
12+
13+
const templateIndex = createIndexByRange([0, 0]);
14+
15+
const userId = 'user';
16+
17+
describe('BatchedOperation', () => {
18+
it('should add Insert operation to batch', () => {
19+
const op1 = new Operation(OperationType.Insert, templateIndex, { payload: 'a' }, userId);
20+
const op2 = new Operation(OperationType.Insert, createIndexByRange([1, 1]), { payload: 'b' }, userId);
21+
22+
const batch = new BatchedOperation(op1);
23+
24+
batch.add(op2);
25+
26+
const operations = batch.operations;
27+
28+
expect(operations).toEqual([op1, op2]);
29+
});
30+
31+
it('should add Delete operation to batch', () => {
32+
const op1 = new Operation(OperationType.Delete, templateIndex, { payload: 'a' }, userId);
33+
const op2 = new Operation(OperationType.Delete, createIndexByRange([1, 1]), { payload: 'b' }, userId);
34+
35+
const batch = new BatchedOperation(op1);
36+
37+
batch.add(op2);
38+
39+
const operations = batch.operations;
40+
41+
expect(operations).toEqual([op1, op2]);
42+
});
43+
44+
describe('from()', () => {
45+
it('should create a new batch from an existing batch', () => {
46+
const op1 = new Operation(OperationType.Insert, templateIndex, { payload: 'a' }, userId);
47+
const op2 = new Operation(OperationType.Insert, createIndexByRange([1, 1]), { payload: 'b' }, userId);
48+
49+
const originalBatch = new BatchedOperation(op1);
50+
51+
originalBatch.add(op2);
52+
53+
54+
const newBatch = BatchedOperation.from(originalBatch);
55+
56+
expect(newBatch.operations).toStrictEqual(originalBatch.operations);
57+
expect(newBatch).not.toBe(originalBatch); // Should be a new instance
58+
});
59+
60+
it('should create a new batch from serialized operation', () => {
61+
const serializedOp: SerializedOperation<OperationType> = new Operation(OperationType.Delete, templateIndex, { payload: 'a' }, userId).serialize();
62+
63+
const batch = BatchedOperation.from(serializedOp);
64+
65+
expect(batch.operations[0].type).toBe(serializedOp.type);
66+
expect(batch.operations[0].data).toEqual(serializedOp.data);
67+
});
68+
});
69+
70+
describe('inverse()', () => {
71+
it('should inverse all operations in the batch', () => {
72+
const op1 = new Operation(OperationType.Insert, templateIndex, { payload: 'a' }, userId);
73+
const op2 = new Operation(OperationType.Insert, createIndexByRange([1, 1]), { payload: 'b' }, userId);
74+
75+
const batch = new BatchedOperation(op1);
76+
77+
batch.add(op2);
78+
79+
const inversedBatch = batch.inverse();
80+
81+
expect(inversedBatch.operations[0].type).toBe(OperationType.Delete);
82+
expect(inversedBatch.operations[1].type).toBe(OperationType.Delete);
83+
});
84+
});
85+
86+
describe('transform()', () => {
87+
it('should transform operations against another operation', () => {
88+
const op1 = new Operation(OperationType.Insert, createIndexByRange([1, 1]), { payload: 'a' }, userId);
89+
const op2 = new Operation(OperationType.Insert, createIndexByRange([2, 2]), { payload: 'b' }, userId);
90+
91+
const batch = new BatchedOperation(op1);
92+
93+
batch.add(op2);
94+
95+
const againstOp = new Operation(OperationType.Insert, createIndexByRange([0, 0]), { payload: 'x' }, 'other-user');
96+
97+
const transformedBatch = batch.transform(againstOp);
98+
99+
expect(transformedBatch).not.toBeNull();
100+
expect(transformedBatch!.operations.length).toBe(2);
101+
// Check if text ranges were shifted by 1 due to insertion
102+
/* eslint-disable @typescript-eslint/no-magic-numbers */
103+
expect(transformedBatch!.operations[0].index.textRange![0]).toBe(2);
104+
expect(transformedBatch!.operations[1].index.textRange![0]).toBe(3);
105+
/* eslint-enable @typescript-eslint/no-magic-numbers */
106+
});
107+
108+
it('should return batch with Neutral operations if no operations can be transformed', () => {
109+
const op = new Operation(OperationType.Insert, createIndexByRange([1, 1]), { payload: 'a' }, userId);
110+
111+
const batch = new BatchedOperation(op);
112+
113+
const deleteIndex = createIndexByRange([0, 2]);
114+
115+
// An operation that would make transformation impossible
116+
const againstOp = new Operation(OperationType.Delete, deleteIndex, { payload: 'a' }, 'other-user');
117+
118+
const transformedBatch = batch.transform(againstOp);
119+
120+
const neutralOp = new Operation(OperationType.Neutral, createIndexByRange([1, 1]), { payload: [] }, userId);
121+
122+
expect(transformedBatch.operations[0]).toEqual(neutralOp);
123+
});
124+
});
125+
126+
describe('canAdd()', () => {
127+
it('should return true for consecutive text operations of same type', () => {
128+
const op1 = new Operation(OperationType.Insert, templateIndex, { payload: 'a' }, userId);
129+
const op2 = new Operation(OperationType.Insert, createIndexByRange([1, 1]), { payload: 'b' }, userId);
130+
131+
const batch = new BatchedOperation(op1);
132+
133+
expect(batch.canAdd(op2)).toBe(true);
134+
});
135+
136+
it('should return false for non-consecutive text operations', () => {
137+
const op1 = new Operation(OperationType.Insert, templateIndex, { payload: 'a' }, userId);
138+
const op2 = new Operation(OperationType.Insert, createIndexByRange([2, 2]), { payload: 'b' }, userId);
139+
140+
const batch = new BatchedOperation(op1);
141+
142+
expect(batch.canAdd(op2)).toBe(false);
143+
});
144+
145+
it('should return false for different operation types', () => {
146+
const op1 = new Operation(OperationType.Insert, templateIndex, { payload: 'a' }, userId);
147+
const op2 = new Operation(OperationType.Delete, createIndexByRange([1, 1]), { payload: 'b' }, userId);
148+
149+
const batch = new BatchedOperation(op1);
150+
151+
expect(batch.canAdd(op2)).toBe(false);
152+
});
153+
154+
it('should return false for modify operations', () => {
155+
const op1 = new Operation(OperationType.Insert, templateIndex, { payload: 'a' }, userId);
156+
const op2 = new Operation(OperationType.Modify, createIndexByRange([1, 1]), { payload: 'b' }, userId);
157+
158+
const batch = new BatchedOperation(op1);
159+
160+
expect(batch.canAdd(op2)).toBe(false);
161+
});
162+
163+
it('should return false when payload is a multi-character string', () => {
164+
const op1 = new Operation(OperationType.Insert, templateIndex, { payload: 'a' }, userId);
165+
const op2 = new Operation(OperationType.Insert, createIndexByRange([1, 1]), { payload: 'bc' }, userId);
166+
167+
const batch = new BatchedOperation(op1);
168+
169+
expect(batch.canAdd(op2)).toBe(false);
170+
});
171+
});
172+
});
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import type { InvertedOperationType } from './Operation.js';
2+
import { Operation, OperationType, type SerializedOperation } from './Operation.js';
3+
4+
/**
5+
* Class to batch Text operations (maybe others in the future) for Undo/Redo purposes
6+
*/
7+
export class BatchedOperation<T extends OperationType = OperationType> extends Operation<T> {
8+
/**
9+
* Array of operations to batch
10+
*/
11+
public operations: (Operation<T> | Operation<OperationType.Neutral>)[] = [];
12+
13+
/**
14+
* Batch constructor function
15+
*
16+
* @param firstOperation - first operation to add
17+
*/
18+
constructor(firstOperation: Operation<T> | Operation<OperationType.Neutral>) {
19+
super(firstOperation.type, firstOperation.index, firstOperation.data, firstOperation.userId, firstOperation.rev);
20+
21+
if (firstOperation !== undefined) {
22+
this.add(firstOperation);
23+
}
24+
}
25+
26+
/**
27+
* Create a new operation batch from an array of operations
28+
*
29+
* @param opBatch - operation batch to clone
30+
*/
31+
public static from<T extends OperationType>(opBatch: BatchedOperation<T>): BatchedOperation<T>;
32+
33+
/**
34+
* Create a new operation batch from a serialized operation
35+
*
36+
* @param json - serialized operation
37+
*/
38+
public static from<T extends OperationType>(json: SerializedOperation<T>): BatchedOperation<T>;
39+
40+
/**
41+
* Create a new operation batch from an operation batch or a serialized operation
42+
*
43+
* @param opBatchOrJSON - operation batch or serialized operation
44+
*/
45+
public static from<T extends OperationType>(opBatchOrJSON: BatchedOperation<T> | SerializedOperation<T>): BatchedOperation<T> {
46+
if (opBatchOrJSON instanceof BatchedOperation) {
47+
/**
48+
* Every batch should have at least one operation
49+
*/
50+
const batch = new BatchedOperation(Operation.from(opBatchOrJSON.operations[0]));
51+
52+
opBatchOrJSON.operations.slice(1)
53+
.forEach((op) => {
54+
/**
55+
* Deep clone operation to the new batch
56+
*/
57+
batch.add(Operation.from(op));
58+
});
59+
60+
return batch;
61+
} else {
62+
const batch = new BatchedOperation<T>(Operation.from(opBatchOrJSON));
63+
64+
return batch;
65+
}
66+
}
67+
68+
/**
69+
* Adds an operation to the batch
70+
* Make sure, that operation could be added to the batch
71+
*
72+
* @param op - operation to add
73+
*/
74+
public add(op: Operation<T> | Operation<OperationType.Neutral>): void {
75+
this.operations.push(op);
76+
}
77+
78+
/**
79+
* Method that inverses all of the operations in the batch
80+
*
81+
* @returns {BatchedOperation<InvertedOperationType<OperationType>>} new batch with inversed operations
82+
*/
83+
public inverse(): BatchedOperation<InvertedOperationType<T>> {
84+
const lastOp = this.operations[this.operations.length - 1];
85+
86+
/**
87+
* Every batch should have at least one operation
88+
*/
89+
const newBatchedOperation = new BatchedOperation<InvertedOperationType<T>>(lastOp.inverse());
90+
91+
this.operations.toReversed().slice(1)
92+
.map(op => newBatchedOperation.add(op.inverse()));
93+
94+
return newBatchedOperation;
95+
}
96+
97+
/**
98+
* Method that transforms all of the operations in the batch against another operation
99+
*
100+
* @param againstOp - operation to transform against
101+
* @returns {BatchedOperation} new batch with transformed operations
102+
*/
103+
public transform<K extends OperationType>(againstOp: Operation<K>): BatchedOperation<T | OperationType.Neutral> {
104+
const transformedOp = this.operations[0].transform(againstOp);
105+
106+
const newBatchedOperation = new BatchedOperation(transformedOp);
107+
108+
this.operations.slice(1).map(op => newBatchedOperation.add(op.transform(againstOp)));
109+
110+
return newBatchedOperation;
111+
}
112+
113+
/**
114+
* Checks if operation can be added to the batch
115+
*
116+
* Only text operations with the same type (Insert/Delete) on the same block and data key could be added
117+
*
118+
* @param op - operation to check
119+
*/
120+
public canAdd(op: Operation): boolean {
121+
/**
122+
* Can't add to batch insertion or deletion of several characters
123+
*/
124+
if (typeof op.data.payload === 'string' && op.data.payload?.length > 1) {
125+
return false;
126+
}
127+
128+
const lastOp = this.operations[this.operations.length - 1];
129+
130+
if (lastOp === undefined) {
131+
return true;
132+
}
133+
134+
/**
135+
* @todo - implement other index types
136+
*/
137+
if (!op.index.isTextIndex || !lastOp.index.isTextIndex) {
138+
return false;
139+
}
140+
141+
if (op.type === OperationType.Modify || lastOp.type === OperationType.Modify) {
142+
return false;
143+
}
144+
145+
if (op.type !== lastOp.type) {
146+
return false;
147+
}
148+
149+
if (op.index.blockIndex !== lastOp.index.blockIndex || op.index.dataKey !== lastOp.index.dataKey) {
150+
return false;
151+
}
152+
153+
return op.index.textRange![0] === lastOp.index.textRange![1] + 1;
154+
}
155+
}

0 commit comments

Comments
 (0)