Skip to content

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

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 163 additions & 0 deletions packages/collaboration-manager/src/BatchedOperation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
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('Batch', () => {
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);
});
});
});
151 changes: 151 additions & 0 deletions packages/collaboration-manager/src/BatchedOperation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
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) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps the linter is not set up, but would be great to have chained calls on the new lines. Here and in the other places

Suggested change
opBatchOrJSON.operations.slice(1).forEach((op) => {
opBatchOrJSON.operations.slice(1)
.forEach((op) => {

/**
* Deep clone operation to the new batch
*/
batch.add(Operation.from(op));
});

return batch as BatchedOperation<T>;
} 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()));

return newBatchedOperation as BatchedOperation<InvertedOperationType<T>>;
}

/**
* 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)));

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;
}

if (!op.index.isTextIndex || !lastOp.index.isTextIndex) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe leave a todo that we might add other index types in the future

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;
}
}
Loading
Loading