/** * 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"; }