I'm implementing a TypeScript library to handle concurrent editing of documents represented by strings, using Operational Transformation. In the beginning the documents where one line strings (without '\n') and my transformation function worked, now I want to consider the document as a char matrix and change the function to fit the new case.
In the beginning the operations were objects of type:
interface Operation {
type: 'ins' | 'del';
id: string;
char: string;
pos: number;
}
Where type specifies if the operation inserts or deletes characters, id is the user id, char is the inserted or deleted character, and pos is the position (starting by 0) at which the operation should be applied. The transformation function that I used was the following:
- inserting a char in a position x that follows the position y of a previous insert operation leads to a change of the parameter x to x+1
- inserting a char in a position x that follows the position y of a previous delete operation leads to a change of the parameter x to x-1
- deleting a char in a position x that follows the position y of a previous insert operation leads to a change of the parameter x to x+1
- deleting a char in a position x that follows the position y of a previous delete operation leads to a change of the parameter x to x-1
function transformOperation(op1: Operation, op2: Operation) {
switch ([op1.type, op2.type].join(' ')) {
case 'ins ins':
if ((Number(op1.pos) < Number(op2.pos)) ||
(Number(op1.pos) == Number(op2.pos) &&
(op1.id > op2.id))) {
return op1;
}
return { ...op1, pos: op1.pos + 1 };
case 'ins del':
if (Number(op1.pos) <= Number(op2.pos)) {
return op1;
}
return { ...op1, pos: op1.pos - 1 };
case 'del ins':
if (Number(op1.pos) < Number(op2.pos)) {
return op1;
}
return { ...op1, pos: op1.pos + 1 };
case 'del del':
if (Number(op1.pos) <= Number(op2.pos)) {
return op1;
}
return { ...op1, pos: op1.pos - 1 }
default:
return op1;
}
}
Then I started considering the documents as characters matrices, the operations became:
interface Operation {
type: 'ins' | 'del';
id: string;
text: string;
startRow: number;
startCol: number;
endRow: number;
endCol: number;
range: number;
}
Where text replaces char given that more than one char could be inserted or deleted; the position in the doc is given by the four properties startRow, startCol, endRow, endCol and the range is the number of characters inserted or deleted.
The new transformation function is:
function samePosition(op1: Operation, op2: Operation) {
return (op1.startRow == op2.startRow) && (op1.startCol == op2.startCol)
}
function preceeding(op1: Operation, op2: Operation) {
return [op1.startRow, op1.startCol] < [op2.startRow, op2.startCol]
}
function modifiedCol(op: Operation, delta: number): Operation {
return {
...op,
startCol: op.startCol + delta,
endCol: op.endCol + delta,
};
}
function transformOperation(op1: Operation, op2: Operation): Operation {
switch (`${op1.type} ${op2.type}`) {
case 'ins ins':
if (preceeding(op1, op2) || samePosition(op1, op2) && (op1.clientId > op2.clientId)) {
return op1;
}
return modifiedCol(op1, op2.text.length);
case 'ins del':
if (preceeding(op1, op2) || samePosition(op1, op2)) {
return op1;
}
return modifiedCol(op1, -op2.range);
case 'del ins':
if (preceeding(op1, op2)) {
return op1;
}
return modifiedCol(op1, op2.text.length);
case 'del del':
if (preceeding(op1, op2) || samePosition(op1, op2)) {
return op1;
}
return modifiedCol(op1, -op2.range);
default: return op1;
}
}
But this doesn't work in certain cases, I'll describe one.
Imagine two users u1 and u2 with the document 'ciao**\n**mondo', when they have this copy of the document they apply respectively:
op1 = { type: 'ins', range:7, id: '2', text: 'p', startCol: 3, endCol: 5, startRow: 0, endRow: 1 }
//and
op2 = { type: 'ins', range:6, id: '1', text: 'n', startCol: 3, endCol: 4, startRow: 0, endRow: 1 }
If u1 applies
op1 = { type: 'ins', range:7, id: '1', text: 'p', startCol: 3, endCol: 5, startRow: 0, endRow: 1 }
it obtains ciap, and then if it receives
op2 = { type: 'ins', range:6, id: '2', text: 'n', startCol: 3, endCol: 4, startRow: 0, endRow: 1 }
then trying to transform op2 against op1 with the previous function it obtains
op2' = { type: 'ins', range:6, id: '1', text: 'n', startCol: 4, endCol: 5, startRow: 0, endRow: 1 }
but it is not correct since the end row remained the same even the new text doesn't have a second line; this implies that the new operation cannot be applied to the document.
I think it is not the only case and I suppose I should consider also the change of row values in the function, any idea of what should be the new function?