livebook/assets/js/lib/delta.js
Jonatan Kłosko dae6d5c9c3
Rename project (#68)
* Rename references

* Update file and directory names

* Fix homepage tests
2021-03-03 22:56:28 +01:00

317 lines
7.4 KiB
JavaScript

/**
* Delta is a format used to represent a set of changes introduced to a text document.
*
* See `Livebook.Delta` for more details.
*
* Also see https://github.com/quilljs/delta
* for a complete implementation of the Delta specification.
*/
export default class Delta {
constructor(ops = []) {
this.ops = ops;
}
/**
* Appends a retain operation.
*/
retain(length) {
if (length <= 0) {
return this;
}
return this.append({ retain: length });
}
/**
* Appends an insert operation.
*/
insert(text) {
if (text === "") {
return this;
}
return this.append({ insert: text });
}
/**
* Appends a delete operation.
*/
delete(length) {
if (length <= 0) {
return this;
}
return this.append({ delete: length });
}
/**
* Appends the given operation.
*
* See `Livebook.Delta.append/2` for more details.
*/
append(op) {
if (this.ops.length === 0) {
this.ops.push(op);
return this;
}
const lastOp = this.ops.pop();
// Insert and delete are commutative, so we always make sure
// to put insert first to preserve the canonical form.
if (isInsert(op) && isDelete(lastOp)) {
return this.append(op).append(lastOp);
}
if (isInsert(op) && isInsert(lastOp)) {
this.ops.push({ insert: lastOp.insert + op.insert });
return this;
}
if (isDelete(op) && isDelete(lastOp)) {
this.ops.push({ delete: lastOp.delete + op.delete });
return this;
}
if (isRetain(op) && isRetain(lastOp)) {
this.ops.push({ retain: lastOp.retain + op.retain });
return this;
}
this.ops.push(lastOp, op);
return this;
}
/**
* Returns a new delta that is equivalent to applying the operations of this delta,
* followed by operations of the given delta.
*/
compose(other) {
const thisIter = new Iterator(this.ops);
const otherIter = new Iterator(other.ops);
const delta = new Delta();
while (thisIter.hasNext() || otherIter.hasNext()) {
if (isInsert(otherIter.peek())) {
delta.append(otherIter.next());
} else if (isDelete(thisIter.peek())) {
delta.append(thisIter.next());
} else {
const length = Math.min(thisIter.peekLength(), otherIter.peekLength());
const thisOp = thisIter.next(length);
const otherOp = otherIter.next(length);
if (isRetain(otherOp)) {
// Either retain or insert, so just apply it.
delta.append(thisOp);
// Other op should be delete, we could be an insert or retain
// Insert + delete cancels out
} else if (isDelete(otherOp) && isRetain(thisOp)) {
delta.append(otherOp);
}
}
}
return delta.__trim();
}
/**
* Transform the given delta against this delta's operations. Returns a new delta.
*
* The method takes a `priority` argument indicates which delta
* is considered to have happened first and is used for conflict resolution.
*
* See `Livebook.Delta.Transformation` for more details.
*/
transform(other, priority) {
if (priority !== "left" && priority !== "right") {
throw new Error(
`Invalid priority "${priority}", should be either "left" or "right"`
);
}
const thisIter = new Iterator(this.ops);
const otherIter = new Iterator(other.ops);
const delta = new Delta();
while (thisIter.hasNext() || otherIter.hasNext()) {
if (
isInsert(thisIter.peek()) &&
(!isInsert(otherIter.peek()) || priority === "left")
) {
const insertLength = operationLength(thisIter.next());
delta.retain(insertLength);
} else if (isInsert(otherIter.peek())) {
delta.append(otherIter.next());
} else {
const length = Math.min(thisIter.peekLength(), otherIter.peekLength());
const thisOp = thisIter.next(length);
const otherOp = otherIter.next(length);
if (isDelete(thisOp)) {
// Our delete either makes their delete redundant or removes their retain
continue;
} else if (isDelete(otherOp)) {
delta.append(otherOp);
} else {
// We retain either their retain or insert
delta.retain(length);
}
}
}
return delta.__trim();
}
__trim() {
if (this.ops.length > 0 && isRetain(this.ops[this.ops.length - 1])) {
this.ops.pop();
}
return this;
}
/**
* Converts the delta to a compact representation, suitable for sending over the network.
*/
toCompressed() {
return this.ops.map((op) => {
if (isInsert(op)) {
return op.insert;
} else if (isRetain(op)) {
return op.retain;
} else if (isDelete(op)) {
return -op.delete;
}
throw new Error(`Invalid operation ${op}`);
});
}
/**
* Builds a new delta from the given compact representation.
*/
static fromCompressed(list) {
return list.reduce((delta, compressedOp) => {
if (typeof compressedOp === "string") {
return delta.insert(compressedOp);
} else if (typeof compressedOp === "number" && compressedOp >= 0) {
return delta.retain(compressedOp);
} else if (typeof compressedOp === "number" && compressedOp < 0) {
return delta.delete(-compressedOp);
}
throw new Error(`Invalid compressed operation ${compressedOp}`);
}, new this());
}
/**
* Returns the result of applying the delta to the given string.
*/
applyToString(string) {
let newString = "";
let index = 0;
this.ops.forEach((op) => {
if (isRetain(op)) {
newString += string.slice(index, index + op.retain);
index += op.retain;
}
if (isInsert(op)) {
newString += op.insert;
}
if (isDelete(op)) {
index += op.delete;
}
});
newString += string.slice(index);
return newString;
}
}
/**
* Operations iterator simplifying the implementation of the delta methods above.
*
* Allows for iterating over operation slices by specifying the desired length.
*/
class Iterator {
constructor(ops) {
this.ops = ops;
this.index = 0;
this.offset = 0;
}
hasNext() {
return this.peekLength() < Infinity;
}
next(length = Infinity) {
const nextOp = this.ops[this.index];
if (nextOp) {
const offset = this.offset;
const opLength = operationLength(nextOp);
if (length >= opLength - offset) {
length = opLength - offset;
this.index += 1;
this.offset = 0;
} else {
this.offset += length;
}
if (isDelete(nextOp)) {
return { delete: length };
} else if (isRetain(nextOp)) {
return { retain: length };
} else if (isInsert(nextOp)) {
return { insert: nextOp.insert.substr(offset, length) };
}
} else {
return { retain: length };
}
}
peek() {
return this.ops[this.index] || { retain: Infinity };
}
peekLength() {
if (this.ops[this.index]) {
return operationLength(this.ops[this.index]) - this.offset;
} else {
return Infinity;
}
}
}
function operationLength(op) {
if (isInsert(op)) {
return op.insert.length;
}
if (isRetain(op)) {
return op.retain;
}
if (isDelete(op)) {
return op.delete;
}
}
export function isInsert(op) {
return typeof op.insert === "string";
}
export function isRetain(op) {
return typeof op.retain === "number";
}
export function isDelete(op) {
return typeof op.delete === "number";
}