mirror of
https://github.com/livebook-dev/livebook.git
synced 2024-11-18 13:53:26 +08:00
936d0af5fb
* Set up markdown rendering, update theme. * Improve focus and handle expanding for markdown cells * Add keybindings for expanding/navigating cells * Improve editor autofocus when navigating with shortcuts * Add tests * Render markdown on the client * Don't render cell initial data and make a request instead
317 lines
7.4 KiB
JavaScript
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";
|
|
}
|