-
Notifications
You must be signed in to change notification settings - Fork 2
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
base: main
Are you sure you want to change the base?
Changes from all commits
9357cba
7a5a0e4
3a48624
f2be325
2a339e1
59e8c4f
bb68221
fc106f6
88c77a1
8e7f12b
afd14a4
0093cea
a8c2de8
2647a7e
61d4dab
a74d393
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,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); | ||
}); | ||
}); | ||
}); |
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) => { | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
|
||||||||
/** | ||||||||
* 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) { | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||||||||
} | ||||||||
} |
Uh oh!
There was an error while loading. Please reload this page.