Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
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
172 changes: 172 additions & 0 deletions packages/collaboration-manager/src/BatchedOperation.spec.ts
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);
});
});
});
155 changes: 155 additions & 0 deletions packages/collaboration-manager/src/BatchedOperation.ts
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>) {
Comment thread
gohabereg marked this conversation as resolved.
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
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

inverse() uses .map(...) for side effects (newBatchedOperation.add(...)). This is misleading because the returned array is unused. Prefer forEach (or a simple loop) to make intent clear and avoid accidental misuse later.

Copilot uses AI. Check for mistakes.
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
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

transform() also uses .map(...) only to call add(...) for side effects. This should be forEach/loop so the code doesn't imply a mapped result is used.

Copilot uses AI. Check for mistakes.
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) {
Comment thread
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;
}
}
Loading
Loading