diff --git a/assets/js/app.js b/assets/js/app.js index dcbcb8e4a..8608712d0 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -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 diff --git a/assets/js/editor/editor_client.js b/assets/js/editor/editor_client.js new file mode 100644 index 000000000..c8ff13191 --- /dev/null +++ b/assets/js/editor/editor_client.js @@ -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); + } +} diff --git a/assets/js/editor/hook_server_adapter.js b/assets/js/editor/hook_server_adapter.js new file mode 100644 index 000000000..c1695f878 --- /dev/null +++ b/assets/js/editor/hook_server_adapter.js @@ -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, + }); + } +} diff --git a/assets/js/editor/index.js b/assets/js/editor/index.js new file mode 100644 index 000000000..e914a077b --- /dev/null +++ b/assets/js/editor/index.js @@ -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; diff --git a/assets/js/editor/monaco.js b/assets/js/editor/monaco.js new file mode 100644 index 000000000..ad5cdb4ca --- /dev/null +++ b/assets/js/editor/monaco.js @@ -0,0 +1,5 @@ +import * as monaco from "monaco-editor/esm/vs/editor/editor.api"; + +// TODO: add Elixir language definition + +export default monaco; diff --git a/assets/js/editor/monaco_editor_adapter.js b/assets/js/editor/monaco_editor_adapter.js new file mode 100644 index 000000000..cbaf33d2a --- /dev/null +++ b/assets/js/editor/monaco_editor_adapter.js @@ -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; + } +} diff --git a/assets/js/lib/delta.js b/assets/js/lib/delta.js new file mode 100644 index 000000000..ff4221574 --- /dev/null +++ b/assets/js/lib/delta.js @@ -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"; +} diff --git a/assets/package-lock.json b/assets/package-lock.json index 1b1b8b566..428f29684 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -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", diff --git a/assets/package.json b/assets/package.json index 776497aaf..f225a66f7 100644 --- a/assets/package.json +++ b/assets/package.json @@ -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", diff --git a/assets/test/lib/delta.test.js b/assets/test/lib/delta.test.js new file mode 100644 index 000000000..4b418ec24 --- /dev/null +++ b/assets/test/lib/delta.test.js @@ -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")); + }); + }); +}); diff --git a/assets/webpack.config.js b/assets/webpack.config.js index 66a6c5ec8..47cb134f3 100644 --- a/assets/webpack.config.js +++ b/assets/webpack.config.js @@ -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()] : []) } diff --git a/lib/live_book/delta.ex b/lib/live_book/delta.ex new file mode 100644 index 000000000..cbaa50ccd --- /dev/null +++ b/lib/live_book/delta.ex @@ -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 diff --git a/lib/live_book/delta/operation.ex b/lib/live_book/delta/operation.ex new file mode 100644 index 000000000..96551dabd --- /dev/null +++ b/lib/live_book/delta/operation.ex @@ -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 diff --git a/lib/live_book/delta/transformation.ex b/lib/live_book/delta/transformation.ex new file mode 100644 index 000000000..584584796 --- /dev/null +++ b/lib/live_book/delta/transformation.ex @@ -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 diff --git a/lib/live_book/evaluator/io_proxy.ex b/lib/live_book/evaluator/io_proxy.ex index 933cdc36a..99541bdd9 100644 --- a/lib/live_book/evaluator/io_proxy.ex +++ b/lib/live_book/evaluator/io_proxy.ex @@ -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 diff --git a/lib/live_book/session.ex b/lib/live_book/session.ex index 1e651776b..9eddcf0b4 100644 --- a/lib/live_book/session.ex +++ b/lib/live_book/session.ex @@ -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) diff --git a/lib/live_book/session/data.ex b/lib/live_book/session/data.ex index 3c5b752d9..f9b80b36f 100644 --- a/lib/live_book/session/data.ex +++ b/lib/live_book/session/data.ex @@ -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 diff --git a/lib/live_book_web/live/cell.ex b/lib/live_book_web/live/cell.ex index 6ec43926a..dd49cbf57 100644 --- a/lib/live_book_web/live/cell.ex +++ b/lib/live_book_web/live/cell.ex @@ -5,7 +5,7 @@ defmodule LiveBookWeb.Cell do ~L"""