-
Notifications
You must be signed in to change notification settings - Fork 3
imp(): implement all operations transformations #115
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
401eab8
be01020
9560a65
04cc9c9
1713809
c4fcbf3
975bd6f
b5e5c2f
ecb157e
be1afa0
320d7fc
72224f8
0bbc7c0
5384a89
56f394d
7e2fac5
c0a28e6
56807b5
82f9dd0
a0a6ce6
7333b65
1415f1a
cb13f51
6c185cb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,172 @@ | ||
| import type { Index } from '@editorjs/model'; | ||
| import { createDataKey, IndexBuilder, type TextRange } from '@editorjs/model'; | ||
| import { BatchedOperation } from './BatchedOperation.js'; | ||
| import type { SerializedOperation } from './Operation.js'; | ||
| import { Operation, OperationType } from './Operation.js'; | ||
|
|
||
| const createIndexByRange = (range: TextRange): Index => new IndexBuilder() | ||
| .addBlockIndex(0) | ||
| .addDataKey(createDataKey('key')) | ||
| .addTextRange(range) | ||
| .build(); | ||
|
|
||
| const templateIndex = createIndexByRange([0, 0]); | ||
|
|
||
| const userId = 'user'; | ||
|
|
||
| describe('BatchedOperation', () => { | ||
| it('should add Insert operation to batch', () => { | ||
| const op1 = new Operation(OperationType.Insert, templateIndex, { payload: 'a' }, userId); | ||
| const op2 = new Operation(OperationType.Insert, createIndexByRange([1, 1]), { payload: 'b' }, userId); | ||
|
|
||
| const batch = new BatchedOperation(op1); | ||
|
|
||
| batch.add(op2); | ||
|
|
||
| const operations = batch.operations; | ||
|
|
||
| expect(operations).toEqual([op1, op2]); | ||
| }); | ||
|
|
||
| it('should add Delete operation to batch', () => { | ||
| const op1 = new Operation(OperationType.Delete, templateIndex, { payload: 'a' }, userId); | ||
| const op2 = new Operation(OperationType.Delete, createIndexByRange([1, 1]), { payload: 'b' }, userId); | ||
|
|
||
| const batch = new BatchedOperation(op1); | ||
|
|
||
| batch.add(op2); | ||
|
|
||
| const operations = batch.operations; | ||
|
|
||
| expect(operations).toEqual([op1, op2]); | ||
| }); | ||
|
|
||
| describe('from()', () => { | ||
| it('should create a new batch from an existing batch', () => { | ||
| const op1 = new Operation(OperationType.Insert, templateIndex, { payload: 'a' }, userId); | ||
| const op2 = new Operation(OperationType.Insert, createIndexByRange([1, 1]), { payload: 'b' }, userId); | ||
|
|
||
| const originalBatch = new BatchedOperation(op1); | ||
|
|
||
| originalBatch.add(op2); | ||
|
|
||
|
|
||
| const newBatch = BatchedOperation.from(originalBatch); | ||
|
|
||
| expect(newBatch.operations).toStrictEqual(originalBatch.operations); | ||
| expect(newBatch).not.toBe(originalBatch); // Should be a new instance | ||
| }); | ||
|
|
||
| it('should create a new batch from serialized operation', () => { | ||
| const serializedOp: SerializedOperation<OperationType> = new Operation(OperationType.Delete, templateIndex, { payload: 'a' }, userId).serialize(); | ||
|
|
||
| const batch = BatchedOperation.from(serializedOp); | ||
|
|
||
| expect(batch.operations[0].type).toBe(serializedOp.type); | ||
| expect(batch.operations[0].data).toEqual(serializedOp.data); | ||
| }); | ||
| }); | ||
|
|
||
| describe('inverse()', () => { | ||
| it('should inverse all operations in the batch', () => { | ||
| const op1 = new Operation(OperationType.Insert, templateIndex, { payload: 'a' }, userId); | ||
| const op2 = new Operation(OperationType.Insert, createIndexByRange([1, 1]), { payload: 'b' }, userId); | ||
|
|
||
| const batch = new BatchedOperation(op1); | ||
|
|
||
| batch.add(op2); | ||
|
|
||
| const inversedBatch = batch.inverse(); | ||
|
|
||
| expect(inversedBatch.operations[0].type).toBe(OperationType.Delete); | ||
| expect(inversedBatch.operations[1].type).toBe(OperationType.Delete); | ||
| }); | ||
| }); | ||
|
|
||
| describe('transform()', () => { | ||
| it('should transform operations against another operation', () => { | ||
| const op1 = new Operation(OperationType.Insert, createIndexByRange([1, 1]), { payload: 'a' }, userId); | ||
| const op2 = new Operation(OperationType.Insert, createIndexByRange([2, 2]), { payload: 'b' }, userId); | ||
|
|
||
| const batch = new BatchedOperation(op1); | ||
|
|
||
| batch.add(op2); | ||
|
|
||
| const againstOp = new Operation(OperationType.Insert, createIndexByRange([0, 0]), { payload: 'x' }, 'other-user'); | ||
|
|
||
| const transformedBatch = batch.transform(againstOp); | ||
|
|
||
| expect(transformedBatch).not.toBeNull(); | ||
| expect(transformedBatch!.operations.length).toBe(2); | ||
| // Check if text ranges were shifted by 1 due to insertion | ||
| /* eslint-disable @typescript-eslint/no-magic-numbers */ | ||
| expect(transformedBatch!.operations[0].index.textRange![0]).toBe(2); | ||
| expect(transformedBatch!.operations[1].index.textRange![0]).toBe(3); | ||
| /* eslint-enable @typescript-eslint/no-magic-numbers */ | ||
| }); | ||
|
|
||
| it('should return batch with Neutral operations if no operations can be transformed', () => { | ||
| const op = new Operation(OperationType.Insert, createIndexByRange([1, 1]), { payload: 'a' }, userId); | ||
|
|
||
| const batch = new BatchedOperation(op); | ||
|
|
||
| const deleteIndex = createIndexByRange([0, 2]); | ||
|
|
||
| // An operation that would make transformation impossible | ||
| const againstOp = new Operation(OperationType.Delete, deleteIndex, { payload: 'a' }, 'other-user'); | ||
|
|
||
| const transformedBatch = batch.transform(againstOp); | ||
|
|
||
| const neutralOp = new Operation(OperationType.Neutral, createIndexByRange([1, 1]), { payload: [] }, userId); | ||
|
|
||
| expect(transformedBatch.operations[0]).toEqual(neutralOp); | ||
| }); | ||
| }); | ||
|
|
||
| describe('canAdd()', () => { | ||
| it('should return true for consecutive text operations of same type', () => { | ||
| const op1 = new Operation(OperationType.Insert, templateIndex, { payload: 'a' }, userId); | ||
| const op2 = new Operation(OperationType.Insert, createIndexByRange([1, 1]), { payload: 'b' }, userId); | ||
|
|
||
| const batch = new BatchedOperation(op1); | ||
|
|
||
| expect(batch.canAdd(op2)).toBe(true); | ||
| }); | ||
|
|
||
| it('should return false for non-consecutive text operations', () => { | ||
| const op1 = new Operation(OperationType.Insert, templateIndex, { payload: 'a' }, userId); | ||
| const op2 = new Operation(OperationType.Insert, createIndexByRange([2, 2]), { payload: 'b' }, userId); | ||
|
|
||
| const batch = new BatchedOperation(op1); | ||
|
|
||
| expect(batch.canAdd(op2)).toBe(false); | ||
| }); | ||
|
|
||
| it('should return false for different operation types', () => { | ||
| const op1 = new Operation(OperationType.Insert, templateIndex, { payload: 'a' }, userId); | ||
| const op2 = new Operation(OperationType.Delete, createIndexByRange([1, 1]), { payload: 'b' }, userId); | ||
|
|
||
| const batch = new BatchedOperation(op1); | ||
|
|
||
| expect(batch.canAdd(op2)).toBe(false); | ||
| }); | ||
|
|
||
| it('should return false for modify operations', () => { | ||
| const op1 = new Operation(OperationType.Insert, templateIndex, { payload: 'a' }, userId); | ||
| const op2 = new Operation(OperationType.Modify, createIndexByRange([1, 1]), { payload: 'b' }, userId); | ||
|
|
||
| const batch = new BatchedOperation(op1); | ||
|
|
||
| expect(batch.canAdd(op2)).toBe(false); | ||
| }); | ||
|
|
||
| it('should return false when payload is a multi-character string', () => { | ||
| const op1 = new Operation(OperationType.Insert, templateIndex, { payload: 'a' }, userId); | ||
| const op2 = new Operation(OperationType.Insert, createIndexByRange([1, 1]), { payload: 'bc' }, userId); | ||
|
|
||
| const batch = new BatchedOperation(op1); | ||
|
|
||
| expect(batch.canAdd(op2)).toBe(false); | ||
| }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,155 @@ | ||
| import type { InvertedOperationType } from './Operation.js'; | ||
| import { Operation, OperationType, type SerializedOperation } from './Operation.js'; | ||
|
|
||
| /** | ||
| * Class to batch Text operations (maybe others in the future) for Undo/Redo purposes | ||
| */ | ||
| export class BatchedOperation<T extends OperationType = OperationType> extends Operation<T> { | ||
| /** | ||
| * Array of operations to batch | ||
| */ | ||
| public operations: (Operation<T> | Operation<OperationType.Neutral>)[] = []; | ||
|
|
||
| /** | ||
| * Batch constructor function | ||
| * | ||
| * @param firstOperation - first operation to add | ||
| */ | ||
| constructor(firstOperation: Operation<T> | Operation<OperationType.Neutral>) { | ||
| super(firstOperation.type, firstOperation.index, firstOperation.data, firstOperation.userId, firstOperation.rev); | ||
|
|
||
| if (firstOperation !== undefined) { | ||
| this.add(firstOperation); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Create a new operation batch from an array of operations | ||
| * | ||
| * @param opBatch - operation batch to clone | ||
| */ | ||
| public static from<T extends OperationType>(opBatch: BatchedOperation<T>): BatchedOperation<T>; | ||
|
|
||
| /** | ||
| * Create a new operation batch from a serialized operation | ||
| * | ||
| * @param json - serialized operation | ||
| */ | ||
| public static from<T extends OperationType>(json: SerializedOperation<T>): BatchedOperation<T>; | ||
|
|
||
| /** | ||
| * Create a new operation batch from an operation batch or a serialized operation | ||
| * | ||
| * @param opBatchOrJSON - operation batch or serialized operation | ||
| */ | ||
| public static from<T extends OperationType>(opBatchOrJSON: BatchedOperation<T> | SerializedOperation<T>): BatchedOperation<T> { | ||
| if (opBatchOrJSON instanceof BatchedOperation) { | ||
| /** | ||
| * Every batch should have at least one operation | ||
| */ | ||
| const batch = new BatchedOperation(Operation.from(opBatchOrJSON.operations[0])); | ||
|
|
||
| opBatchOrJSON.operations.slice(1) | ||
| .forEach((op) => { | ||
| /** | ||
| * Deep clone operation to the new batch | ||
| */ | ||
| batch.add(Operation.from(op)); | ||
| }); | ||
|
|
||
| return batch; | ||
| } else { | ||
| const batch = new BatchedOperation<T>(Operation.from(opBatchOrJSON)); | ||
|
|
||
| return batch; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Adds an operation to the batch | ||
| * Make sure, that operation could be added to the batch | ||
| * | ||
| * @param op - operation to add | ||
| */ | ||
| public add(op: Operation<T> | Operation<OperationType.Neutral>): void { | ||
| this.operations.push(op); | ||
| } | ||
|
|
||
| /** | ||
| * Method that inverses all of the operations in the batch | ||
| * | ||
| * @returns {BatchedOperation<InvertedOperationType<OperationType>>} new batch with inversed operations | ||
| */ | ||
| public inverse(): BatchedOperation<InvertedOperationType<T>> { | ||
| const lastOp = this.operations[this.operations.length - 1]; | ||
|
|
||
| /** | ||
| * Every batch should have at least one operation | ||
| */ | ||
| const newBatchedOperation = new BatchedOperation<InvertedOperationType<T>>(lastOp.inverse()); | ||
|
|
||
| this.operations.toReversed().slice(1) | ||
| .map(op => newBatchedOperation.add(op.inverse())); | ||
|
|
||
|
Comment on lines
+89
to
+93
|
||
| return newBatchedOperation; | ||
| } | ||
|
|
||
| /** | ||
| * Method that transforms all of the operations in the batch against another operation | ||
| * | ||
| * @param againstOp - operation to transform against | ||
| * @returns {BatchedOperation} new batch with transformed operations | ||
| */ | ||
| public transform<K extends OperationType>(againstOp: Operation<K>): BatchedOperation<T | OperationType.Neutral> { | ||
| const transformedOp = this.operations[0].transform(againstOp); | ||
|
|
||
| const newBatchedOperation = new BatchedOperation(transformedOp); | ||
|
|
||
| this.operations.slice(1).map(op => newBatchedOperation.add(op.transform(againstOp))); | ||
|
|
||
|
Comment on lines
+103
to
+109
|
||
| return newBatchedOperation; | ||
| } | ||
|
|
||
| /** | ||
| * Checks if operation can be added to the batch | ||
| * | ||
| * Only text operations with the same type (Insert/Delete) on the same block and data key could be added | ||
| * | ||
| * @param op - operation to check | ||
| */ | ||
| public canAdd(op: Operation): boolean { | ||
| /** | ||
| * Can't add to batch insertion or deletion of several characters | ||
| */ | ||
| if (typeof op.data.payload === 'string' && op.data.payload?.length > 1) { | ||
| return false; | ||
| } | ||
|
|
||
| const lastOp = this.operations[this.operations.length - 1]; | ||
|
|
||
| if (lastOp === undefined) { | ||
| return true; | ||
| } | ||
|
|
||
| /** | ||
| * @todo - implement other index types | ||
| */ | ||
| if (!op.index.isTextIndex || !lastOp.index.isTextIndex) { | ||
|
gohabereg marked this conversation as resolved.
|
||
| return false; | ||
| } | ||
|
|
||
| if (op.type === OperationType.Modify || lastOp.type === OperationType.Modify) { | ||
| return false; | ||
| } | ||
|
|
||
| if (op.type !== lastOp.type) { | ||
| return false; | ||
| } | ||
|
|
||
| if (op.index.blockIndex !== lastOp.index.blockIndex || op.index.dataKey !== lastOp.index.dataKey) { | ||
| return false; | ||
| } | ||
|
|
||
| return op.index.textRange![0] === lastOp.index.textRange![1] + 1; | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.