mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-09-04 03:54:24 +08:00
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:
parent
80cd651b0f
commit
3e6a4adce2
26 changed files with 1985 additions and 33 deletions
|
@ -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
|
||||
|
|
155
assets/js/editor/editor_client.js
Normal file
155
assets/js/editor/editor_client.js
Normal 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);
|
||||
}
|
||||
}
|
48
assets/js/editor/hook_server_adapter.js
Normal file
48
assets/js/editor/hook_server_adapter.js
Normal 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
93
assets/js/editor/index.js
Normal 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;
|
5
assets/js/editor/monaco.js
Normal file
5
assets/js/editor/monaco.js
Normal file
|
@ -0,0 +1,5 @@
|
|||
import * as monaco from "monaco-editor/esm/vs/editor/editor.api";
|
||||
|
||||
// TODO: add Elixir language definition
|
||||
|
||||
export default monaco;
|
112
assets/js/editor/monaco_editor_adapter.js
Normal file
112
assets/js/editor/monaco_editor_adapter.js
Normal 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
290
assets/js/lib/delta.js
Normal 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";
|
||||
}
|
61
assets/package-lock.json
generated
61
assets/package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
255
assets/test/lib/delta.test.js
Normal file
255
assets/test/lib/delta.test.js
Normal 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"));
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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
178
lib/live_book/delta.ex
Normal 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
|
106
lib/live_book/delta/operation.ex
Normal file
106
lib/live_book/delta/operation.ex
Normal 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
|
109
lib/live_book/delta/transformation.ex
Normal file
109
lib/live_book/delta/transformation.ex
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
273
test/live_book/delta/transformation_test.exs
Normal file
273
test/live_book/delta/transformation_test.exs
Normal 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
|
72
test/live_book/delta_test.exs
Normal file
72
test/live_book/delta_test.exs
Normal 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
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -1 +1 @@
|
|||
ExUnit.start(assert_receive_timeout: 500)
|
||||
ExUnit.start(assert_receive_timeout: 300)
|
||||
|
|
Loading…
Add table
Reference in a new issue