Implement collaborative text editing (#10)

* Set up editor and client side delta handling

* Synchronize deltas on the server

* Document the client code, add more tests

* Implement delta on the server, use compact representation when transmitting changes

* Simplify transformation implementation and add documentation

* Add session and data tests

* Add more delta tests

* Clean up evaluator tests wait timeout
This commit is contained in:
Jonatan Kłosko 2021-01-21 13:11:45 +01:00 committed by GitHub
parent 80cd651b0f
commit 3e6a4adce2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 1985 additions and 33 deletions

View file

@ -5,9 +5,11 @@ import { Socket } from "phoenix";
import NProgress from "nprogress";
import { LiveSocket } from "phoenix_live_view";
import ContentEditable from "./content_editable";
import Editor from "./editor";
const Hooks = {
ContentEditable,
Editor,
};
const csrfToken = document

View file

@ -0,0 +1,155 @@
/**
* A manager associated with a particular editor instance,
* which is responsible for controlling client-server communication
* and synchronizing the sent/received changes.
*
* This class takes `serverAdapter` and `editorAdapter` objects
* that encapsulate the logic relevant for each part.
*
* ## Changes synchronization
*
* When the local editor emits a change (represented as delta),
* the client sends this delta to the server and waits for an acknowledgement.
* Until the acknowledgement comes, the client keeps all further
* edits in buffer.
* The server may send either an acknowledgement or other client's delta.
* It's important to note that those messages come in what the server
* believes is chronological order, so any delta received before
* the acknowledgement should be treated as if it happened before
* our unacknowledged delta.
* Other client's delta is transformed against the local unacknowledged
* deltas and applied to the editor.
*/
export default class EditorClient {
constructor(serverAdapter, editorAdapter, revision) {
this.serverAdapter = serverAdapter;
this.editorAdapter = editorAdapter;
this.revision = revision;
this.state = new Synchronized(this);
this.editorAdapter.onDelta((delta) => {
this.__handleClientDelta(delta);
});
this.serverAdapter.onDelta((delta) => {
this.__handleServerDelta(delta);
});
this.serverAdapter.onAcknowledgement(() => {
this.__handleServerAcknowledgement();
});
}
__handleClientDelta(delta) {
this.state = this.state.onClientDelta(delta);
}
__handleServerDelta(delta) {
this.revision++;
this.state = this.state.onServerDelta(delta);
}
__handleServerAcknowledgement() {
this.state = this.state.onServerAcknowledgement();
}
applyDelta(delta) {
this.editorAdapter.applyDelta(delta);
}
sendDelta(delta) {
this.revision++;
this.serverAdapter.sendDelta(delta, this.revision);
}
}
/**
* Client is in this state when there is no delta pending acknowledgement
* (the client is fully in sync with the server).
*/
class Synchronized {
constructor(client) {
this.client = client;
}
onClientDelta(delta) {
this.client.sendDelta(delta);
return new AwaitingConfirm(this.client, delta);
}
onServerDelta(delta) {
this.client.applyDelta(delta);
return this;
}
onServerAcknowledgement() {
throw new Error("Unexpected server acknowledgement.");
}
}
/**
* Client is in this state when the client sent one delta and waits
* for an acknowledgement, while there are no other deltas in a buffer.
*/
class AwaitingConfirm {
constructor(client, awaitedDelta) {
this.client = client;
this.awaitedDelta = awaitedDelta;
}
onClientDelta(delta) {
return new AwaitingWithBuffer(this.client, this.awaitedDelta, delta);
}
onServerDelta(delta) {
// We consider the incoming delta to happen first
// (because that's the case from the server's perspective).
const deltaPrime = this.awaitedDelta.transform(delta, "right");
this.client.applyDelta(deltaPrime);
const awaitedDeltaPrime = delta.transform(this.awaitedDelta, "left");
return new AwaitingConfirm(this.client, awaitedDeltaPrime);
}
onServerAcknowledgement() {
return new Synchronized(this.client);
}
}
/**
* Client is in this state when the client sent one delta and waits
* for an acknowledgement, while there are more deltas in a buffer.
*/
class AwaitingWithBuffer {
constructor(client, awaitedDelta, buffer) {
this.client = client;
this.awaitedDelta = awaitedDelta;
this.buffer = buffer;
}
onClientDelta(delta) {
const newBuffer = this.buffer.compose(delta);
return new AwaitingWithBuffer(this.client, this.awaitedDelta, newBuffer);
}
onServerDelta(delta) {
// We consider the incoming delta to happen first
// (because that's the case from the server's perspective).
// Delta transformed against awaitedDelta
const deltaPrime = this.awaitedDelta.transform(delta, "right");
// Delta transformed against both awaitedDelta and the buffer (appropriate for applying to the editor)
const deltaBis = this.buffer.transform(deltaPrime, "right");
this.client.applyDelta(deltaBis);
const awaitedDeltaPrime = delta.transform(this.awaitedDelta, "left");
const bufferPrime = deltaPrime.transform(this.buffer, "left");
return new AwaitingWithBuffer(this.client, awaitedDeltaPrime, bufferPrime);
}
onServerAcknowledgement() {
this.client.sendDelta(this.buffer);
return new AwaitingConfirm(this.client, this.buffer);
}
}

View file

@ -0,0 +1,48 @@
import Delta from "../lib/delta";
/**
* Encapsulates logic related to sending/receiving messages from the server.
*
* Uses the given hook instance socket for the communication.
*/
export default class HookServerAdapter {
constructor(hook, cellId) {
this.hook = hook;
this.cellId = cellId;
this._onDelta = null;
this._onAcknowledgement = null;
this.hook.handleEvent(`cell_delta:${this.cellId}`, ({ delta }) => {
this._onDelta && this._onDelta(Delta.fromCompressed(delta));
});
this.hook.handleEvent(`cell_acknowledgement:${this.cellId}`, () => {
this._onAcknowledgement && this._onAcknowledgement();
});
}
/**
* Registers a callback called whenever a new delta comes from the server.
*/
onDelta(callback) {
this._onDelta = callback;
}
/**
* Registers a callback called when delta acknowledgement comes from the server.
*/
onAcknowledgement(callback) {
this._onAcknowledgement = callback;
}
/**
* Sends the given delta to the server.
*/
sendDelta(delta, revision) {
this.hook.pushEvent("cell_delta", {
cell_id: this.cellId,
delta: delta.toCompressed(),
revision,
});
}
}

93
assets/js/editor/index.js Normal file
View file

@ -0,0 +1,93 @@
import monaco from "./monaco";
import EditorClient from "./editor_client";
import MonacoEditorAdapter from "./monaco_editor_adapter";
import HookServerAdapter from "./hook_server_adapter";
/**
* A hook managing an editable cell.
*
* Mounts a Monaco Editor and provides real-time collaboration mechanism
* by sending all changes as `Delta` objects to the server
* and handling such objects sent by other clients.
*
* Configuration:
*
* * `data-cell-id` - id of the cell being edited
* * `data-type` - editor type (i.e. language), either "markdown" or "elixir" is expected
*
* Additionally the root element should have a direct `div` child
* with `data-source` and `data-revision` providing the initial values.
*/
const Editor = {
mounted() {
this.cellId = this.el.dataset.cellId;
this.type = this.el.dataset.type;
const editorContainer = this.el.querySelector("div");
if (!editorContainer) {
throw new Error("Editor Hook root element should have a div child");
}
const source = editorContainer.dataset.source;
const revision = +editorContainer.dataset.revision;
this.editor = this.__mountEditor(editorContainer);
this.editor.getModel().setValue(source);
new EditorClient(
new HookServerAdapter(this, this.cellId),
new MonacoEditorAdapter(this.editor),
revision
);
},
__mountEditor(editorContainer) {
const editor = monaco.editor.create(editorContainer, {
language: this.type,
value: "",
scrollbar: {
vertical: "hidden",
handleMouseWheel: false,
},
minimap: {
enabled: false,
},
overviewRulerLanes: 0,
scrollBeyondLastLine: false,
quickSuggestions: false,
renderIndentGuides: false,
occurrencesHighlight: false,
renderLineHighlight: "none",
});
editor.getModel().updateOptions({
tabSize: 2,
});
editor.updateOptions({
autoIndent: true,
tabSize: 2,
formatOnType: true,
});
// Dynamically adjust editor height to the content, see https://github.com/microsoft/monaco-editor/issues/794
function adjustEditorLayout() {
const contentHeight = editor.getContentHeight();
editorContainer.style.height = `${contentHeight}px`;
editor.layout();
}
editor.onDidContentSizeChange(adjustEditorLayout);
adjustEditorLayout();
window.addEventListener("resize", (event) => {
editor.layout();
});
return editor;
},
};
export default Editor;

View file

@ -0,0 +1,5 @@
import * as monaco from "monaco-editor/esm/vs/editor/editor.api";
// TODO: add Elixir language definition
export default monaco;

View file

@ -0,0 +1,112 @@
import monaco from "./monaco";
import Delta, { isDelete, isInsert, isRetain } from "../lib/delta";
/**
* Encapsulates logic related to getting/applying changes to the editor.
*
* Uses the given Monaco editor instance.
*/
export default class MonacoEditorAdapter {
constructor(editor) {
this.editor = editor;
this._onDelta = null;
this.editor.onDidChangeModelContent((event) => {
if (this.ignoreChange) {
return;
}
const delta = this.__deltaFromEditorChange(event);
this._onDelta && this._onDelta(delta);
});
}
/**
* Registers a callback called whenever the user makes a change
* to the editor content. The change is represented by a delta object.
*/
onDelta(callback) {
this._onDelta = callback;
}
/**
* Applies the given delta to the editor content.
*/
applyDelta(delta) {
const operations = this.__deltaToEditorOperations(delta);
this.ignoreChange = true;
this.editor.getModel().pushEditOperations([], operations);
this.ignoreChange = false;
}
__deltaFromEditorChange(event) {
const deltas = event.changes.map((change) => {
const { rangeOffset, rangeLength, text } = change;
const delta = new Delta();
if (rangeOffset) {
delta.retain(rangeOffset);
}
if (rangeLength) {
delta.delete(rangeLength);
}
if (text) {
delta.insert(text);
}
return delta;
});
return deltas.reduce((delta1, delta2) => delta1.compose(delta2));
}
__deltaToEditorOperations(delta) {
const model = this.editor.getModel();
const operations = [];
let index = 0;
delta.ops.forEach((op) => {
if (isRetain(op)) {
index += op.retain;
}
if (isInsert(op)) {
const start = model.getPositionAt(index);
operations.push({
forceMoveMarkers: true,
range: new monaco.Range(
start.lineNumber,
start.column,
start.lineNumber,
start.column
),
text: op.insert,
});
}
if (isDelete(op)) {
const start = model.getPositionAt(index);
const end = model.getPositionAt(index + op.delete);
operations.push({
forceMoveMarkers: false,
range: new monaco.Range(
start.lineNumber,
start.column,
end.lineNumber,
end.column
),
text: null,
});
index += op.delete;
}
});
return operations;
}
}

290
assets/js/lib/delta.js Normal file
View file

@ -0,0 +1,290 @@
/**
* 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 given 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());
}
}
/**
* 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";
}

View file

@ -4651,6 +4651,40 @@
"integrity": "sha512-0btnI/H8f2pavGMN8w40mlSKOfTK2SVJmBfBeVIj3kNw0swwgzyRq0d5TJVOwodFmtvpPeWPN/MCcfuWF0Ezbw==",
"dev": true
},
"file-loader": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/file-loader/-/file-loader-6.2.0.tgz",
"integrity": "sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==",
"dev": true,
"requires": {
"loader-utils": "^2.0.0",
"schema-utils": "^3.0.0"
},
"dependencies": {
"loader-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.0.tgz",
"integrity": "sha512-rP4F0h2RaWSvPEkD7BLDFQnvSf+nK+wr3ESUjNTyAGobqrijmW92zc+SO6d4p4B1wh7+B/Jg1mkQe5NYUEHtHQ==",
"dev": true,
"requires": {
"big.js": "^5.2.2",
"emojis-list": "^3.0.0",
"json5": "^2.1.2"
}
},
"schema-utils": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.0.0.tgz",
"integrity": "sha512-6D82/xSzO094ajanoOSbe4YvXWMfn2A//8Y1+MUqFAJul5Bs+yn36xbK9OtNDcRVSBJ9jjeoXftM6CfztsjOAA==",
"dev": true,
"requires": {
"@types/json-schema": "^7.0.6",
"ajv": "^6.12.5",
"ajv-keywords": "^3.5.2"
}
}
}
},
"file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
@ -8163,6 +8197,33 @@
"integrity": "sha512-1lM+BMLGuDfsdwf3rsgBSrxJwAZHFIrQ8YR61xIqdHo0uNKI9M52wNpHSrliZATJp51On6JD0AfRxd4YGSU0lw==",
"dev": true
},
"monaco-editor": {
"version": "0.21.2",
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.21.2.tgz",
"integrity": "sha512-jS51RLuzMaoJpYbu7F6TPuWpnWTLD4kjRW0+AZzcryvbxrTwhNy1KC9yboyKpgMTahpUbDUsuQULoo0GV1EPqg=="
},
"monaco-editor-webpack-plugin": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/monaco-editor-webpack-plugin/-/monaco-editor-webpack-plugin-2.1.0.tgz",
"integrity": "sha512-DG7Dpo/ItWEOl/BG2egc/UIiHoCbHjq0EOF0E6eJQT+6QNZBOfSVU4GxaXG+kQJXB8rauxli96Xp1ITnNLZtSw==",
"dev": true,
"requires": {
"loader-utils": "^2.0.0"
},
"dependencies": {
"loader-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.0.tgz",
"integrity": "sha512-rP4F0h2RaWSvPEkD7BLDFQnvSf+nK+wr3ESUjNTyAGobqrijmW92zc+SO6d4p4B1wh7+B/Jg1mkQe5NYUEHtHQ==",
"dev": true,
"requires": {
"big.js": "^5.2.2",
"emojis-list": "^3.0.0",
"json5": "^2.1.2"
}
}
}
},
"move-concurrently": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz",

View file

@ -3,16 +3,17 @@
"description": " ",
"license": "MIT",
"scripts": {
"deploy": "webpack --mode production",
"deploy": "NODE_ENV=production webpack --mode production",
"watch": "webpack --mode development --watch",
"format": "prettier --trailing-comma es5 --write {js,css}/**/*.{js,json,css,scss,md}",
"format": "prettier --trailing-comma es5 --write {js,test,css}/**/*.{js,json,css,scss,md}",
"test": "jest --watch"
},
"dependencies": {
"monaco-editor": "^0.21.2",
"nprogress": "^0.2.0",
"phoenix": "file:../deps/phoenix",
"phoenix_html": "file:../deps/phoenix_html",
"phoenix_live_view": "file:../deps/phoenix_live_view",
"nprogress": "^0.2.0"
"phoenix_live_view": "file:../deps/phoenix_live_view"
},
"devDependencies": {
"@babel/core": "^7.0.0",
@ -21,9 +22,11 @@
"babel-loader": "^8.0.0",
"copy-webpack-plugin": "^5.1.1",
"css-loader": "^3.4.2",
"file-loader": "^6.2.0",
"hard-source-webpack-plugin": "^0.13.1",
"jest": "^26.6.3",
"mini-css-extract-plugin": "^0.9.0",
"monaco-editor-webpack-plugin": "^2.1.0",
"optimize-css-assets-webpack-plugin": "^5.0.1",
"postcss": "^8.2.3",
"postcss-loader": "^4.1.0",

View file

@ -0,0 +1,255 @@
import Delta from "../../js/lib/delta";
describe("Delta", () => {
describe("compose", () => {
test("insert with insert", () => {
const a = new Delta().insert("A");
const b = new Delta().insert("B");
const expected = new Delta().insert("B").insert("A");
expect(a.compose(b)).toEqual(expected);
});
test("insert with retain", () => {
const a = new Delta().insert("A");
const b = new Delta().retain(1);
const expected = new Delta().insert("A");
expect(a.compose(b)).toEqual(expected);
});
test("insert with delete", () => {
const a = new Delta().insert("A");
const b = new Delta().delete(1);
const expected = new Delta();
expect(a.compose(b)).toEqual(expected);
});
test("retain with insert", () => {
const a = new Delta().retain(1);
const b = new Delta().insert("B");
const expected = new Delta().insert("B");
expect(a.compose(b)).toEqual(expected);
});
test("retain with retain", () => {
const a = new Delta().retain(1);
const b = new Delta().retain(1);
const expected = new Delta();
expect(a.compose(b)).toEqual(expected);
});
test("retain with delete", () => {
const a = new Delta().retain(1);
const b = new Delta().delete(1);
const expected = new Delta().delete(1);
expect(a.compose(b)).toEqual(expected);
});
test("delete with insert", () => {
const a = new Delta().delete(1);
const b = new Delta().insert("B");
const expected = new Delta().insert("B").delete(1);
expect(a.compose(b)).toEqual(expected);
});
test("delete with retain", () => {
const a = new Delta().delete(1);
const b = new Delta().retain(1);
const expected = new Delta().delete(1);
expect(a.compose(b)).toEqual(expected);
});
test("delete with delete", () => {
const a = new Delta().delete(1);
const b = new Delta().delete(1);
const expected = new Delta().delete(2);
expect(a.compose(b)).toEqual(expected);
});
test("insert in the middle of a text", () => {
const a = new Delta().insert("Hello");
const b = new Delta().retain(3).insert("X");
const expected = new Delta().insert("HelXlo");
expect(a.compose(b)).toEqual(expected);
});
test("insert and delete with different ordering", () => {
const a = new Delta().insert("Hello");
const b = new Delta().insert("Hello");
const insertFirst = new Delta().retain(3).insert("X").delete(1);
const deleteFirst = new Delta().retain(3).delete(1).insert("X");
const expected = new Delta().insert("HelXo");
expect(a.compose(insertFirst)).toEqual(expected);
expect(b.compose(deleteFirst)).toEqual(expected);
});
test("retain then insert with delete entire text", () => {
const a = new Delta().retain(4).insert("Hello");
const b = new Delta().delete(9);
const expected = new Delta().delete(4);
expect(a.compose(b)).toEqual(expected);
});
test("retain more than the length of text", () => {
const a = new Delta().insert("Hello");
const b = new Delta().retain(10);
const expected = new Delta().insert("Hello");
expect(a.compose(b)).toEqual(expected);
});
});
describe("transform", () => {
test("insert against insert", () => {
const a = new Delta().insert("A");
const b = new Delta().insert("B");
const bPrimeAssumingAFirst = new Delta().retain(1).insert("B");
const bPrimeAssumingBFirst = new Delta().insert("B");
expect(a.transform(b, "left")).toEqual(bPrimeAssumingAFirst);
expect(a.transform(b, "right")).toEqual(bPrimeAssumingBFirst);
});
test("retain against insert", () => {
const a = new Delta().insert("A");
// Add insert, so that trailing retain is not trimmed (same in other places)
const b = new Delta().retain(1).insert("B");
const bPrime = new Delta().retain(2).insert("B");
expect(a.transform(b, "right")).toEqual(bPrime);
});
test("delete against insert", () => {
const a = new Delta().insert("A");
const b = new Delta().delete(1);
const bPrime = new Delta().retain(1).delete(1);
expect(a.transform(b, "right")).toEqual(bPrime);
});
test("insert against delete", () => {
const a = new Delta().delete(1);
const b = new Delta().insert("B");
const bPrime = new Delta().insert("B");
expect(a.transform(b, "right")).toEqual(bPrime);
});
test("retain against delete", () => {
const a = new Delta().delete(1);
const b = new Delta().retain(1).insert("B");
const bPrime = new Delta().insert("B");
expect(a.transform(b, "right")).toEqual(bPrime);
});
test("delete against delete", () => {
const a = new Delta().delete(1);
const b = new Delta().delete(1);
const bPrime = new Delta();
expect(a.transform(b, "right")).toEqual(bPrime);
});
test("insert against retain", () => {
const a = new Delta().retain(1).insert("A");
const b = new Delta().insert("B");
const bPrime = new Delta().insert("B");
expect(a.transform(b, "right")).toEqual(bPrime);
});
test("retain against retain", () => {
const a = new Delta().retain(1).insert("A");
const b = new Delta().retain(1).insert("B");
const bPrime = new Delta().retain(1).insert("B");
expect(a.transform(b, "right")).toEqual(bPrime);
});
test("delete against retain", () => {
const a = new Delta().retain(1);
const b = new Delta().delete(1);
const bPrime = new Delta().delete(1);
expect(a.transform(b, "right")).toEqual(bPrime);
});
test("multiple edits", () => {
const a = new Delta().retain(2).insert("aa").delete(5);
const b = new Delta()
.retain(1)
.insert("b")
.delete(5)
.retain(1)
.insert("bb");
const bPrimeAssumingBFirst = new Delta()
.retain(1)
.insert("b")
.delete(1)
.retain(2)
.insert("bb");
const aPrimeAssumingBFirst = new Delta().retain(2).insert("aa").delete(1);
expect(a.transform(b, "right")).toEqual(bPrimeAssumingBFirst);
expect(b.transform(a, "left")).toEqual(aPrimeAssumingBFirst);
});
test("conflicting appends", () => {
const a = new Delta().retain(3).insert("aa");
const b = new Delta().retain(3).insert("bb");
const bPrimeAssumingBFirst = new Delta().retain(3).insert("bb");
const aPrimeAssumingBFirst = new Delta().retain(5).insert("aa");
expect(a.transform(b, "right")).toEqual(bPrimeAssumingBFirst);
expect(b.transform(a, "left")).toEqual(aPrimeAssumingBFirst);
});
test("prepend and append", () => {
const a = new Delta().insert("aa");
const b = new Delta().retain(3).insert("bb");
const bPrime = new Delta().retain(5).insert("bb");
const aPrime = new Delta().insert("aa");
expect(a.transform(b, "right")).toEqual(bPrime);
expect(b.transform(a, "left")).toEqual(aPrime);
});
test("trailing deletes with differing lengths", () => {
const a = new Delta().retain(2).delete(1);
const b = new Delta().delete(3);
const bPrime = new Delta().delete(2);
const aPrime = new Delta();
expect(a.transform(b, "right")).toEqual(bPrime);
expect(b.transform(a, "left")).toEqual(aPrime);
});
test("immutability", () => {
const a = new Delta().insert("A");
const b = new Delta().insert("B");
const bPrime = new Delta().retain(1).insert("B");
expect(a.transform(b, "left")).toEqual(bPrime);
expect(a).toEqual(new Delta().insert("A"));
expect(b).toEqual(new Delta().insert("B"));
});
});
});

View file

@ -5,6 +5,7 @@ const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const TerserPlugin = require('terser-webpack-plugin');
const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const MonacoWebpackPlugin = require('monaco-editor-webpack-plugin');
module.exports = (env, options) => {
const devMode = options.mode !== 'production';
@ -41,12 +42,19 @@ module.exports = (env, options) => {
'css-loader',
'postcss-loader',
],
}
},
{
test: /\.ttf$/,
use: ['file-loader'],
},
]
},
plugins: [
new MiniCssExtractPlugin({ filename: '../css/app.css' }),
new CopyWebpackPlugin([{ from: 'static/', to: '../' }])
new CopyWebpackPlugin([{ from: 'static/', to: '../' }]),
new MonacoWebpackPlugin({
languages: ['markdown']
})
]
.concat(devMode ? [new HardSourceWebpackPlugin()] : [])
}

178
lib/live_book/delta.ex Normal file
View file

@ -0,0 +1,178 @@
defmodule LiveBook.Delta do
@moduledoc false
# Delta is a format used to represent a set of changes
# introduced to a text document.
#
# By design, delta is suitable for Operational Transformation
# and is hence our primary building block in collaborative text editing.
#
# For a detailed write-up see https://quilljs.com/docs/delta
# and https://quilljs.com/guides/designing-the-delta-format.
# The specification covers rich-text editing, while we only
# need to work with plain-text, so we use a subset of the specification
# with operations listed in `LiveBook.Delta.Operation`.
#
# Also see https://hexdocs.pm/text_delta/TextDelta.html
# for a complete implementation of the Delta specification.
defstruct ops: []
alias LiveBook.Delta
alias LiveBook.Delta.{Operation, Transformation}
@type t :: %Delta{ops: list(Operation.t())}
@doc """
Creates a new delta, optionally taking a list of operations.
"""
@spec new(list(Operation.t())) :: t()
def new(opts \\ [])
def new([]), do: %Delta{}
def new(ops), do: Enum.reduce(ops, new(), &append(&2, &1))
@doc """
Appends a new `:insert` operation to the given delta.
"""
@spec insert(t(), String.t()) :: t()
def insert(delta, string) do
append(delta, Operation.insert(string))
end
@doc """
Appends a new `:retain` operation to the given delta.
"""
@spec retain(t(), non_neg_integer()) :: t()
def retain(delta, length) do
append(delta, Operation.retain(length))
end
@doc """
Appends a new `:delete` operation to the given delta.
"""
@spec delete(t(), non_neg_integer()) :: t()
def delete(delta, length) do
append(delta, Operation.delete(length))
end
@doc """
Appends an operation to the given delta.
The specification imposes two constraints:
1. Delta must be *compact* - there must be no shorter equivalent delta.
2. Delta must be *canonical* - there is just a single valid representation of the given change.
To satisfy these constraints we follow two rules:
1. Delete followed by insert is swapped to ensure that insert goes first.
2. Operations of the same type are merged.
"""
@spec append(t(), Operation.t()) :: t()
def append(delta, op) do
Map.update!(delta, :ops, fn ops ->
ops
|> Enum.reverse()
|> compact(op)
|> Enum.reverse()
end)
end
defp compact(ops, {:insert, ""}), do: ops
defp compact(ops, {:retain, 0}), do: ops
defp compact(ops, {:delete, 0}), do: ops
defp compact([], new_op), do: [new_op]
defp compact([{:delete, _} = del | ops_remainder], {:insert, _} = ins) do
ops_remainder
|> compact(ins)
|> compact(del)
end
defp compact([{:retain, len_a} | ops_remainder], {:retain, len_b}) do
[Operation.retain(len_a + len_b) | ops_remainder]
end
defp compact([{:insert, str_a} | ops_remainder], {:insert, str_b}) do
[Operation.insert(str_a <> str_b) | ops_remainder]
end
defp compact([{:delete, len_a} | ops_remainder], {:delete, len_b}) do
[Operation.delete(len_a + len_b) | ops_remainder]
end
defp compact(ops, new_op), do: [new_op | ops]
@doc """
Removes trailing retain operations from the given delta.
"""
@spec trim(t()) :: t()
def trim(%Delta{ops: []} = delta), do: delta
def trim(delta) do
case List.last(delta.ops) do
{:retain, _} ->
Map.update!(delta, :ops, fn ops ->
ops |> Enum.reverse() |> tl() |> Enum.reverse()
end)
_ ->
delta
end
end
@doc """
Returns the result of applying `delta` to `string`.
"""
@spec apply_to_string(t(), String.t()) :: String.t()
def apply_to_string(delta, string) do
do_apply_to_string(delta.ops, string)
end
defp do_apply_to_string([], string), do: string
defp do_apply_to_string([{:retain, n} | ops], string) do
{left, right} = String.split_at(string, n)
left <> do_apply_to_string(ops, right)
end
defp do_apply_to_string([{:insert, inserted} | ops], string) do
inserted <> do_apply_to_string(ops, string)
end
defp do_apply_to_string([{:delete, n} | ops], string) do
do_apply_to_string(ops, String.slice(string, n..-1))
end
@doc """
Converts the given delta to a compact representation,
suitable for sending over the network.
## Examples
iex> delta = %LiveBook.Delta{ops: [retain: 2, insert: "hey", delete: 3]}
iex> LiveBook.Delta.to_compressed(delta)
[2, "hey", -3]
"""
@spec to_compressed(t()) :: list(Operation.compressed_t())
def to_compressed(delta) do
Enum.map(delta.ops, &Operation.to_compressed/1)
end
@doc """
Builds a new delta from the given compact representation.
## Examples
iex> LiveBook.Delta.from_compressed([2, "hey", -3])
%LiveBook.Delta{ops: [retain: 2, insert: "hey", delete: 3]}
"""
@spec from_compressed(list(Operation.compressed_t())) :: t()
def from_compressed(list) do
list
|> Enum.map(&Operation.from_compressed/1)
|> new()
end
defdelegate transform(left, right, priority), to: Transformation
end

View file

@ -0,0 +1,106 @@
defmodule LiveBook.Delta.Operation do
@moduledoc false
# An peration represents an atomic change applicable to a text.
#
# For plain-text (our use case) an operation can be either of:
#
# * `{:insert, string}` - insert the given text at the current position
# * `{:retain, length}` - preserve the given number of characters (effectively moving the cursor)
# * `{:delete, number}` - delete the given number of characters starting from the current position
import Kernel, except: [length: 1]
@type t :: insert | retain | delete
@type insert :: {:insert, String.t()}
@type retain :: {:retain, non_neg_integer()}
@type delete :: {:delete, non_neg_integer()}
@type compressed_t :: String.t() | non_neg_integer() | neg_integer()
@spec insert(String.t()) :: t()
def insert(string), do: {:insert, string}
@spec insert(non_neg_integer()) :: t()
def retain(length), do: {:retain, length}
@spec delete(non_neg_integer()) :: t()
def delete(length), do: {:delete, length}
@doc """
Returns length of text affected by a given operation.
"""
@spec length(t()) :: non_neg_integer()
def length({:insert, string}), do: String.length(string)
def length({:retain, length}), do: length
def length({:delete, length}), do: length
@doc """
Splits the given operation into two at the specified offset.
"""
@spec split_at(t(), non_neg_integer()) :: {t(), t()}
def split_at(op, position)
def split_at({:insert, string}, position) do
{part_one, part_two} = String.split_at(string, position)
{insert(part_one), insert(part_two)}
end
def split_at({:retain, length}, position) do
{retain(position), retain(length - position)}
end
def split_at({:delete, length}, position) do
{delete(position), delete(length - position)}
end
@doc """
Converts the given operation to a basic type uniquely identifying it.
"""
@spec to_compressed(t()) :: compressed_t()
def to_compressed({:insert, string}), do: string
def to_compressed({:retain, length}), do: length
def to_compressed({:delete, length}), do: -length
@doc """
Converts the given basic type to the corresponding operation.
"""
@spec from_compressed(compressed_t()) :: t()
def from_compressed(string) when is_binary(string), do: {:insert, string}
def from_compressed(length) when is_integer(length) and length >= 0, do: {:retain, length}
def from_compressed(length) when is_integer(length) and length < 0, do: {:delete, -length}
@doc """
Modifies the given operation lists, so that their heads
have the same operation length.
## Examples
iex> left = [{:insert, "cat"}]
iex> right = [{:retain, 2}, {:delete, 2}]
iex> LiveBook.Delta.Operation.align_heads(left, right)
{
[{:insert, "ca"}, {:insert, "t"}],
[{:retain, 2}, {:delete, 2}]
}
"""
@spec align_heads(list(t()), list(t())) :: {list(t()), list(t())}
def align_heads([head_a | tail_a], [head_b | tail_b]) do
len_a = length(head_a)
len_b = length(head_b)
cond do
len_a > len_b ->
{left_a, right_a} = split_at(head_a, len_b)
{[left_a, right_a | tail_a], [head_b | tail_b]}
len_a < len_b ->
{left_b, right_b} = split_at(head_b, len_a)
{[head_a | tail_a], [left_b, right_b | tail_b]}
true ->
{[head_a | tail_a], [head_b | tail_b]}
end
end
end

View file

@ -0,0 +1,109 @@
defmodule LiveBook.Delta.Transformation do
@moduledoc false
# Implementation of the Operational Transformation concept for deltas.
#
# The transformation allows for conflict resolution in concurrent editing.
# Consider delta `Oa` and delta `Ob` that occurred at the same time against the same text state `S`.
# The resulting new text states are `S ∘ Oa` and `S ∘ Ob` respectively.
# Now for each text state we would like to apply the other delta,
# so that both texts converge to the same state, i.e.:
#
# `S ∘ Oa ∘ transform(Oa, Ob) = S ∘ Ob ∘ transform(Ob, Oa)`
#
# That's the high-level idea.
# To actually achieve convergence we have to introduce a linear order of operations.
# This way we can resolve conflicts - e.g. if two deltas insert a text at the same
# position, we have to unambiguously determine which takes precedence.
# A reasonable solution is to have a server process where all
# the clients send deltas, as it naturally imposes the necessary ordering.
alias LiveBook.Delta
alias LiveBook.Delta.Operation
@type priority :: :left | :right
@doc """
Transforms `right` delta against the `left` delta.
Assuming both deltas represent changes applied to the same
document state, this operation results in modified `right` delta
that represents effectively the same changes (preserved intent),
but works on the document with `left` delta already applied.
The `priority` indicates which delta is considered to have
happened first and is used for conflict resolution.
"""
@spec transform(Delta.t(), Delta.t(), priority()) :: Delta.t()
def transform(left, right, priority) do
do_transform(left.ops, right.ops, priority, Delta.new())
|> Delta.trim()
end
defp do_transform(_ops_a, [] = _ops_b, _priority, result) do
result
end
defp do_transform([], ops_b, _priority, result) do
Enum.reduce(ops_b, result, &Delta.append(&2, &1))
end
defp do_transform([{:insert, _} | _] = ops_a, [{:insert, _} | _] = ops_b, :left, result) do
[ins_a | remainder_a] = ops_a
retain = make_retain(ins_a)
do_transform(remainder_a, ops_b, :left, Delta.append(result, retain))
end
defp do_transform([{:insert, _} | _] = ops_a, [{:insert, _} | _] = ops_b, :right, result) do
[ins_b | remainder_b] = ops_b
do_transform(ops_a, remainder_b, :right, Delta.append(result, ins_b))
end
defp do_transform([{:insert, _} | _] = ops_a, [{:retain, _} | _] = ops_b, priority, result) do
[ins_a | remainder_a] = ops_a
retain = make_retain(ins_a)
do_transform(remainder_a, ops_b, priority, Delta.append(result, retain))
end
defp do_transform([{:insert, _} | _] = ops_a, [{:delete, _} | _] = ops_b, priority, result) do
[ins_a | remainder_a] = ops_a
retain = make_retain(ins_a)
do_transform(remainder_a, ops_b, priority, Delta.append(result, retain))
end
defp do_transform([{:delete, _} | _] = ops_a, [{:insert, _} | _] = ops_b, priority, result) do
[ins_b | remainder_b] = ops_b
do_transform(ops_a, remainder_b, priority, Delta.append(result, ins_b))
end
defp do_transform([{:delete, _} | _] = ops_a, [{:retain, _} | _] = ops_b, priority, result) do
{[_del_a | remainder_a], [_ret_b | remainder_b]} = Operation.align_heads(ops_a, ops_b)
do_transform(remainder_a, remainder_b, priority, result)
end
defp do_transform([{:delete, _} | _] = ops_a, [{:delete, _} | _] = ops_b, priority, result) do
{[_del_a | remainder_a], [_del_b | remainder_b]} = Operation.align_heads(ops_a, ops_b)
do_transform(remainder_a, remainder_b, priority, result)
end
defp do_transform([{:retain, _} | _] = ops_a, [{:insert, _} | _] = ops_b, priority, result) do
[ins_b | remainder_b] = ops_b
do_transform(ops_a, remainder_b, priority, Delta.append(result, ins_b))
end
defp do_transform([{:retain, _} | _] = ops_a, [{:retain, _} | _] = ops_b, priority, result) do
{[ret | remainder_a], [ret | remainder_b]} = Operation.align_heads(ops_a, ops_b)
do_transform(remainder_a, remainder_b, priority, Delta.append(result, ret))
end
defp do_transform([{:retain, _} | _] = ops_a, [{:delete, _} | _] = ops_b, priority, result) do
{[_ret_a | remainder_a], [del_b | remainder_b]} = Operation.align_heads(ops_a, ops_b)
do_transform(remainder_a, remainder_b, priority, Delta.append(result, del_b))
end
defp make_retain(op) do
op
|> Operation.length()
|> Operation.retain()
end
end

View file

@ -38,8 +38,8 @@ defmodule LiveBook.Evaluator.IOProxy do
The possible messages are:
* `{:evaluator_stdout, ref, string}` - for output requests,
where `ref` is the given evaluation reference and `string` is the output.
* `{:evaluator_stdout, ref, string}` - for output requests,
where `ref` is the given evaluation reference and `string` is the output.
"""
@spec configure(pid(), pid(), Evaluator.ref()) :: :ok
def configure(pid, target, ref) do

View file

@ -15,7 +15,7 @@ defmodule LiveBook.Session do
use GenServer, restart: :temporary
alias LiveBook.Session.Data
alias LiveBook.{Evaluator, EvaluatorSupervisor, Utils, Notebook}
alias LiveBook.{Evaluator, EvaluatorSupervisor, Utils, Notebook, Delta}
alias LiveBook.Notebook.{Cell, Section}
@type state :: %{
@ -132,6 +132,14 @@ defmodule LiveBook.Session do
GenServer.cast(name(session_id), {:set_section_name, section_id, name})
end
@doc """
Asynchronously sends a cell delta to apply to the server.
"""
@spec apply_cell_delta(id(), pid(), Cell.id(), Delta.t(), Data.cell_revision()) :: :ok
def apply_cell_delta(session_id, from, cell_id, delta, revision) do
GenServer.cast(name(session_id), {:apply_cell_delta, from, cell_id, delta, revision})
end
@doc """
Synchronously stops the server.
"""
@ -206,6 +214,11 @@ defmodule LiveBook.Session do
handle_operation(state, operation)
end
def handle_cast({:apply_cell_delta, from, cell_id, delta, revision}, state) do
operation = {:apply_cell_delta, from, cell_id, delta, revision}
handle_operation(state, operation)
end
@impl true
def handle_info({:DOWN, _, :process, pid, _}, state) do
{:noreply, %{state | client_pids: List.delete(state.client_pids, pid)}}
@ -264,6 +277,8 @@ defmodule LiveBook.Session do
state
end
defp handle_action(state, _action), do: state
defp broadcast_operation(session_id, operation) do
message = {:operation, operation}
Phoenix.PubSub.broadcast(LiveBook.PubSub, "sessions:#{session_id}", message)

View file

@ -23,7 +23,7 @@ defmodule LiveBook.Session.Data do
:deleted_cells
]
alias LiveBook.{Notebook, Evaluator}
alias LiveBook.{Notebook, Evaluator, Delta}
alias LiveBook.Notebook.{Cell, Section}
@type t :: %__MODULE__{
@ -43,12 +43,13 @@ defmodule LiveBook.Session.Data do
@type cell_info :: %{
validity_status: cell_validity_status(),
evaluation_status: cell_evaluation_status(),
revision: non_neg_integer(),
# TODO: specify it's a list of deltas, once defined
deltas: list(),
revision: cell_revision(),
deltas: list(Delta.t()),
evaluated_at: DateTime.t()
}
@type cell_revision :: non_neg_integer()
@type cell_validity_status :: :fresh | :evaluated | :stale
@type cell_evaluation_status :: :ready | :queued | :evaluating
@ -65,11 +66,13 @@ defmodule LiveBook.Session.Data do
| {:cancel_cell_evaluation, Cell.id()}
| {:set_notebook_name, String.t()}
| {:set_section_name, Section.id(), String.t()}
| {:apply_cell_delta, pid(), Cell.id(), Delta.t(), cell_revision()}
@type action ::
{:start_evaluation, Cell.t(), Section.t()}
| {:stop_evaluation, Section.t()}
| {:forget_evaluation, Cell.t(), Section.t()}
| {:broadcast_delta, pid(), Cell.t(), Delta.t()}
@doc """
Returns a fresh notebook session state.
@ -248,6 +251,19 @@ defmodule LiveBook.Session.Data do
end
end
def apply_operation(data, {:apply_cell_delta, from, cell_id, delta, revision}) do
with {:ok, cell, _} <- Notebook.fetch_cell_and_section(data.notebook, cell_id),
cell_info <- data.cell_infos[cell.id],
true <- 0 < revision and revision <= cell_info.revision + 1 do
data
|> with_actions()
|> apply_delta(from, cell, delta, revision)
|> wrap_ok()
else
_ -> :error
end
end
# ===
defp with_actions(data, actions \\ []), do: {data, actions}
@ -412,6 +428,27 @@ defmodule LiveBook.Session.Data do
|> set!(notebook: Notebook.update_section(data.notebook, section.id, &%{&1 | name: name}))
end
defp apply_delta({data, _} = data_actions, from, cell, delta, revision) do
info = data.cell_infos[cell.id]
deltas_ahead = Enum.take(info.deltas, -(info.revision - revision + 1))
transformed_new_delta =
Enum.reduce(deltas_ahead, delta, fn delta_ahead, transformed_new_delta ->
Delta.transform(delta_ahead, transformed_new_delta, :left)
end)
new_source = Delta.apply_to_string(transformed_new_delta, cell.source)
data_actions
|> set!(notebook: Notebook.update_cell(data.notebook, cell.id, &%{&1 | source: new_source}))
|> set_cell_info!(cell.id,
deltas: info.deltas ++ [transformed_new_delta],
revision: info.revision + 1
)
|> add_action({:broadcast_delta, from, %{cell | source: new_source}, transformed_new_delta})
end
defp add_action({data, actions}, action) do
{data, actions ++ [action]}
end

View file

@ -5,7 +5,7 @@ defmodule LiveBookWeb.Cell do
~L"""
<div phx-click="focus_cell"
phx-value-cell_id="<%= @cell.id %>"
class="flex flex-col p-2 relative mr-10 border-2 border-gray-200 rounded border-opacity-0 <%= if @focused, do: "border-opacity-100"%>">
class="flex flex-col relative mr-10 border-2 border-gray-200 rounded border-opacity-0 <%= if @focused, do: "border-opacity-100"%>">
<div class="flex flex-col items-center space-y-2 absolute right-0 top-0 -mr-10">
<button class="text-gray-500 hover:text-current">
<%= Icons.svg(:play, class: "h-6") %>
@ -15,14 +15,13 @@ defmodule LiveBookWeb.Cell do
</button>
</div>
<div
id="cell-<%= @cell.id %>"
id="cell-<%= @cell.id %>-editor"
phx-hook="Editor"
phx-update="ignore"
data-id="<%= @cell.id %>"
data-type="<%= @cell.type %>"
>
<div class="h-20 flex opacity-20">
<%= @cell.type |> Atom.to_string() |> String.capitalize() %> cell placeholder
data-cell-id="<%= @cell.id %>"
data-type="<%= @cell.type %>">
<div data-source="<%= @cell.source %>"
data-revision="<%= @cell_info.revision %>">
</div>
</div>
</div>

View file

@ -30,6 +30,7 @@ defmodule LiveBookWeb.Section do
<%= for {cell, index} <- Enum.with_index(@section.cells) do %>
<%= live_component @socket, LiveBookWeb.Cell,
cell: cell,
cell_info: @cell_infos[cell.id],
focused: cell.id == @focused_cell_id %>
<%= live_component @socket, LiveBookWeb.InsertCellActions,
section_id: @section.id,

View file

@ -1,7 +1,7 @@
defmodule LiveBookWeb.SessionLive do
use LiveBookWeb, :live_view
alias LiveBook.{SessionSupervisor, Session}
alias LiveBook.{SessionSupervisor, Session, Delta}
@impl true
def mount(%{"id" => session_id}, _session, socket) do
@ -73,6 +73,7 @@ defmodule LiveBookWeb.SessionLive do
<%= live_component @socket, LiveBookWeb.Section,
section: section,
selected: section.id == @selected_section_id,
cell_infos: @data.cell_infos,
focused_cell_id: @focused_cell_id %>
<% end %>
</div>
@ -135,6 +136,17 @@ defmodule LiveBookWeb.SessionLive do
{:noreply, socket}
end
def handle_event(
"cell_delta",
%{"cell_id" => cell_id, "delta" => delta, "revision" => revision},
socket
) do
delta = Delta.from_compressed(delta)
Session.apply_cell_delta(socket.assigns.session_id, self(), cell_id, delta, revision)
{:noreply, socket}
end
defp normalize_name(name) do
name
|> String.trim()
@ -148,11 +160,26 @@ defmodule LiveBookWeb.SessionLive do
@impl true
def handle_info({:operation, operation}, socket) do
case Session.Data.apply_operation(socket.assigns.data, operation) do
{:ok, data, _actions} ->
{:noreply, assign(socket, data: data)}
{:ok, data, actions} ->
new_socket = assign(socket, data: data)
{:noreply, handle_actions(new_socket, actions)}
:error ->
{:noreply, socket}
end
end
defp handle_actions(state, actions) do
Enum.reduce(actions, state, &handle_action(&2, &1))
end
defp handle_action(socket, {:broadcast_delta, from, cell, delta}) do
if from == self() do
push_event(socket, "cell_acknowledgement:#{cell.id}", %{})
else
push_event(socket, "cell_delta:#{cell.id}", %{delta: Delta.to_compressed(delta)})
end
end
defp handle_action(socket, _action), do: socket
end

View file

@ -0,0 +1,273 @@
defmodule LiveBook.Delta.TransformationText do
use ExUnit.Case, async: true
alias LiveBook.Delta
describe "transform" do
test "insert against insert" do
a =
Delta.new()
|> Delta.insert("A")
b =
Delta.new()
|> Delta.insert("B")
b_prime_assuming_a_first =
Delta.new()
|> Delta.retain(1)
|> Delta.insert("B")
b_prime_assuming_b_first =
Delta.new()
|> Delta.insert("B")
assert Delta.transform(a, b, :left) == b_prime_assuming_a_first
assert Delta.transform(a, b, :right) == b_prime_assuming_b_first
end
test "retain against insert" do
a =
Delta.new()
|> Delta.insert("A")
b =
Delta.new()
|> Delta.retain(1)
# Add insert, so that trailing retain is not trimmed (same in other places)
|> Delta.insert("B")
b_prime =
Delta.new()
|> Delta.retain(2)
|> Delta.insert("B")
assert Delta.transform(a, b, :right) == b_prime
end
test "delete against insert" do
a =
Delta.new()
|> Delta.insert("A")
b =
Delta.new()
|> Delta.delete(1)
b_prime =
Delta.new()
|> Delta.retain(1)
|> Delta.delete(1)
assert Delta.transform(a, b, :right) == b_prime
end
test "insert against delete" do
a =
Delta.new()
|> Delta.delete(1)
b =
Delta.new()
|> Delta.insert("B")
b_prime =
Delta.new()
|> Delta.insert("B")
assert Delta.transform(a, b, :right) == b_prime
end
test "retain against delete" do
a =
Delta.new()
|> Delta.delete(1)
b =
Delta.new()
|> Delta.retain(1)
|> Delta.insert("B")
b_prime =
Delta.new()
|> Delta.insert("B")
assert Delta.transform(a, b, :right) == b_prime
end
test "delete against delete" do
a =
Delta.new()
|> Delta.delete(1)
b =
Delta.new()
|> Delta.delete(1)
b_prime = Delta.new()
assert Delta.transform(a, b, :right) == b_prime
end
test "insert against retain" do
a =
Delta.new()
|> Delta.retain(1)
|> Delta.insert("A")
b =
Delta.new()
|> Delta.insert("B")
b_prime =
Delta.new()
|> Delta.insert("B")
assert Delta.transform(a, b, :right) == b_prime
end
test "retain against retain" do
a =
Delta.new()
|> Delta.retain(1)
|> Delta.insert("A")
b =
Delta.new()
|> Delta.retain(1)
|> Delta.insert("B")
b_prime =
Delta.new()
|> Delta.retain(1)
|> Delta.insert("B")
assert Delta.transform(a, b, :right) == b_prime
end
test "delete against retain" do
a =
Delta.new()
|> Delta.retain(1)
b =
Delta.new()
|> Delta.delete(1)
b_prime =
Delta.new()
|> Delta.delete(1)
assert Delta.transform(a, b, :right) == b_prime
end
test "multiple edits" do
a =
Delta.new()
# Move 2 positions
|> Delta.retain(2)
# Insert a word
|> Delta.insert("aa")
# Delete a word
|> Delta.delete(5)
b =
Delta.new()
# Move 1 position
|> Delta.retain(1)
# Insert a word
|> Delta.insert("b")
# Delete a word
|> Delta.delete(5)
# Move 1 position
|> Delta.retain(1)
# Insert another word
|> Delta.insert("bb")
b_prime_assuming_b_first =
Delta.new()
|> Delta.retain(1)
|> Delta.insert("b")
|> Delta.delete(1)
|> Delta.retain(2)
|> Delta.insert("bb")
a_prime_assuming_b_first =
Delta.new()
|> Delta.retain(2)
|> Delta.insert("aa")
|> Delta.delete(1)
assert Delta.transform(a, b, :right) == b_prime_assuming_b_first
assert Delta.transform(b, a, :left) == a_prime_assuming_b_first
end
test "conflicting appends" do
a =
Delta.new()
|> Delta.retain(3)
|> Delta.insert("aa")
b =
Delta.new()
|> Delta.retain(3)
|> Delta.insert("bb")
b_prime_assuming_b_first =
Delta.new()
|> Delta.retain(3)
|> Delta.insert("bb")
a_prime_assuming_b_first =
Delta.new()
|> Delta.retain(5)
|> Delta.insert("aa")
assert Delta.transform(a, b, :right) == b_prime_assuming_b_first
assert Delta.transform(b, a, :left) == a_prime_assuming_b_first
end
test "prepend and append" do
a =
Delta.new()
|> Delta.insert("aa")
b =
Delta.new()
|> Delta.retain(3)
|> Delta.insert("bb")
b_prime_assuming_b_first =
Delta.new()
|> Delta.retain(5)
|> Delta.insert("bb")
a_prime_assuming_b_first =
Delta.new()
|> Delta.insert("aa")
assert Delta.transform(a, b, :right) == b_prime_assuming_b_first
assert Delta.transform(b, a, :left) == a_prime_assuming_b_first
end
test "trailing deletes with different lengths" do
a =
Delta.new()
|> Delta.retain(2)
|> Delta.delete(1)
b =
Delta.new()
|> Delta.delete(3)
b_prime =
Delta.new()
|> Delta.delete(2)
a_prime = Delta.new()
assert Delta.transform(a, b, :right) == b_prime
assert Delta.transform(b, a, :left) == a_prime
end
end
end

View file

@ -0,0 +1,72 @@
defmodule LiveBook.DeltaTest do
use ExUnit.Case, async: true
alias LiveBook.Delta
alias LiveBook.Delta.Operation
doctest Delta
describe "append/2" do
test "ignores empty operations" do
assert Delta.append(Delta.new(), {:insert, ""}) == %Delta{ops: []}
assert Delta.append(Delta.new(), {:retain, 0}) == %Delta{ops: []}
assert Delta.append(Delta.new(), {:delete, 0}) == %Delta{ops: []}
end
test "given empty delta just appends the operation" do
delta = Delta.new()
op = Operation.insert("cats")
assert Delta.append(delta, op) == %Delta{ops: [insert: "cats"]}
end
test "merges consecutive inserts" do
delta = Delta.new() |> Delta.insert("cats")
op = Operation.insert(" rule")
assert Delta.append(delta, op) == %Delta{ops: [insert: "cats rule"]}
end
test "merges consecutive retains" do
delta = Delta.new() |> Delta.retain(2)
op = Operation.retain(2)
assert Delta.append(delta, op) == %Delta{ops: [retain: 4]}
end
test "merges consecutive delete" do
delta = Delta.new() |> Delta.delete(2)
op = Operation.delete(2)
assert Delta.append(delta, op) == %Delta{ops: [delete: 4]}
end
test "given insert appended after delete, swaps the operations" do
delta = Delta.new() |> Delta.delete(2)
op = Operation.insert("cats")
assert Delta.append(delta, op) == %Delta{ops: [insert: "cats", delete: 2]}
end
end
describe "apply_to_string/2" do
test "prepend" do
string = "cats"
delta = Delta.new() |> Delta.insert("fat ")
assert Delta.apply_to_string(delta, string) == "fat cats"
end
test "insert in the middle" do
string = "cats"
delta = Delta.new() |> Delta.retain(3) |> Delta.insert("'")
assert Delta.apply_to_string(delta, string) == "cat's"
end
test "delete" do
string = "cats"
delta = Delta.new() |> Delta.retain(1) |> Delta.delete(2)
assert Delta.apply_to_string(delta, string) == "cs"
end
test "replace" do
string = "cats"
delta = Delta.new() |> Delta.retain(1) |> Delta.delete(2) |> Delta.insert("ar")
assert Delta.apply_to_string(delta, string) == "cars"
end
end
end

View file

@ -74,31 +74,32 @@ defmodule LiveBook.EvaluatorTest do
evaluator: evaluator
} do
code = """
defmodule Math do
defmodule LiveBook.EvaluatorTest.Stacktrace.Math do
def bad_math do
result = 1 / 0
{:ok, result}
end
end
defmodule Cat do
defmodule LiveBook.EvaluatorTest.Stacktrace.Cat do
def meow do
Math.bad_math()
LiveBook.EvaluatorTest.Stacktrace.Math.bad_math()
:ok
end
end
Cat.meow()
LiveBook.EvaluatorTest.Stacktrace.Cat.meow()
"""
Evaluator.evaluate_code(evaluator, self(), code, :code_1)
expected_stacktrace = [
{Math, :bad_math, 0, [file: 'nofile', line: 3]},
{Cat, :meow, 0, [file: 'nofile', line: 10]}
{LiveBook.EvaluatorTest.Stacktrace.Math, :bad_math, 0, [file: 'nofile', line: 3]},
{LiveBook.EvaluatorTest.Stacktrace.Cat, :meow, 0, [file: 'nofile', line: 10]}
]
assert_receive {:evaluator_response, :code_1, {:error, _kind, _error, ^expected_stacktrace}}
# Note: evaluating module definitions is relatively slow, so we use a higher wait timeout.
assert_receive {:evaluator_response, :code_1, {:error, _kind, _error, ^expected_stacktrace}}, 1000
end
end

View file

@ -2,6 +2,7 @@ defmodule LiveBook.Session.DataTest do
use ExUnit.Case, async: true
alias LiveBook.Session.Data
alias LiveBook.Delta
describe "apply_operation/2 given :insert_section" do
test "adds new section to notebook and session info" do
@ -478,6 +479,92 @@ defmodule LiveBook.Session.DataTest do
end
end
describe "apply_operation/2 given :apply_cell_delta" do
test "returns an error given invalid cell id" do
data = Data.new()
operation = {:apply_cell_delta, self(), "nonexistent", Delta.new(), 1}
assert :error = Data.apply_operation(data, operation)
end
test "returns an error given invalid revision" do
data =
data_after_operations!([
{:insert_section, 0, "s1"},
{:insert_cell, "s1", 0, :elixir, "c1"}
])
delta = Delta.new() |> Delta.insert("cats")
operation = {:apply_cell_delta, self(), "c1", delta, 5}
assert :error = Data.apply_operation(data, operation)
end
test "updates cell source according to the given delta" do
data =
data_after_operations!([
{:insert_section, 0, "s1"},
{:insert_cell, "s1", 0, :elixir, "c1"}
])
delta = Delta.new() |> Delta.insert("cats")
operation = {:apply_cell_delta, self(), "c1", delta, 1}
assert {:ok,
%{
notebook: %{
sections: [
%{cells: [%{source: "cats"}]}
]
},
cell_infos: %{"c1" => %{revision: 1}}
}, _actions} = Data.apply_operation(data, operation)
end
test "transforms the delta if the revision is not the most recent" do
delta1 = Delta.new() |> Delta.insert("cats")
data =
data_after_operations!([
{:insert_section, 0, "s1"},
{:insert_cell, "s1", 0, :elixir, "c1"},
{:apply_cell_delta, self(), "c1", delta1, 1}
])
delta2 = Delta.new() |> Delta.insert("tea")
operation = {:apply_cell_delta, self(), "c1", delta2, 1}
assert {:ok,
%{
notebook: %{
sections: [
%{cells: [%{source: "catstea"}]}
]
},
cell_infos: %{"c1" => %{revision: 2}}
}, _} = Data.apply_operation(data, operation)
end
test "returns broadcast delta action with the transformed delta" do
delta1 = Delta.new() |> Delta.insert("cats")
data =
data_after_operations!([
{:insert_section, 0, "s1"},
{:insert_cell, "s1", 0, :elixir, "c1"},
{:apply_cell_delta, self(), "c1", delta1, 1}
])
delta2 = Delta.new() |> Delta.insert("tea")
operation = {:apply_cell_delta, self(), "c1", delta2, 1}
from = self()
transformed_delta2 = Delta.new() |> Delta.retain(4) |> Delta.insert("tea")
assert {:ok, _data, [{:broadcast_delta, ^from, _cell, ^transformed_delta2}]} =
Data.apply_operation(data, operation)
end
end
defp data_after_operations!(operations) do
Enum.reduce(operations, Data.new(), fn operation, data ->
case Data.apply_operation(data, operation) do

View file

@ -1,7 +1,7 @@
defmodule LiveBook.SessionTest do
use ExUnit.Case, async: true
alias LiveBook.Session
alias LiveBook.{Session, Delta}
setup do
{:ok, _} = Session.start_link("1")
@ -100,7 +100,22 @@ defmodule LiveBook.SessionTest do
{section_id, _cell_id} = insert_section_and_cell(session_id)
Session.set_section_name(session_id, section_id, "Chapter 1")
assert_receive {:operation, {:set_section_name, section_id, "Chapter 1"}}
assert_receive {:operation, {:set_section_name, ^section_id, "Chapter 1"}}
end
end
describe "apply_cell_delta/5" do
test "sends a cell delta operation to subscribers", %{session_id: session_id} do
Phoenix.PubSub.subscribe(LiveBook.PubSub, "sessions:#{session_id}")
{_section_id, cell_id} = insert_section_and_cell(session_id)
from = self()
delta = Delta.new() |> Delta.insert("cats")
revision = 1
Session.apply_cell_delta(session_id, from, cell_id, delta, revision)
assert_receive {:operation, {:apply_cell_delta, ^from, ^cell_id, ^delta, ^revision}}
end
end

View file

@ -1 +1 @@
ExUnit.start(assert_receive_timeout: 500)
ExUnit.start(assert_receive_timeout: 300)