livebook/assets/js/cell/live_editor.js
2021-07-02 16:52:38 +02:00

354 lines
10 KiB
JavaScript

import monaco from "./live_editor/monaco";
import EditorClient from "./live_editor/editor_client";
import MonacoEditorAdapter from "./live_editor/monaco_editor_adapter";
import HookServerAdapter from "./live_editor/hook_server_adapter";
import RemoteUser from "./live_editor/remote_user";
/**
* Mounts cell source editor with real-time collaboration mechanism.
*/
class LiveEditor {
constructor(hook, container, cellId, type, source, revision) {
this.hook = hook;
this.container = container;
this.cellId = cellId;
this.type = type;
this.source = source;
this._onChange = null;
this._onBlur = null;
this._onCursorSelectionChange = null;
this._remoteUserByClientPid = {};
this.__mountEditor();
if (type === "elixir") {
this.__setupCompletion();
this.__setupFormatting();
}
const serverAdapter = new HookServerAdapter(hook, cellId);
const editorAdapter = new MonacoEditorAdapter(this.editor);
this.editorClient = new EditorClient(
serverAdapter,
editorAdapter,
revision
);
this.editorClient.onDelta((delta) => {
this.source = delta.applyToString(this.source);
this._onChange && this._onChange(this.source);
});
this.editor.onDidFocusEditorWidget(() => {
this.editor.updateOptions({ matchBrackets: "always" });
});
this.editor.onDidBlurEditorWidget(() => {
this.editor.updateOptions({ matchBrackets: "never" });
this._onBlur && this._onBlur();
});
this.editor.onDidChangeCursorSelection((event) => {
this._onCursorSelectionChange &&
this._onCursorSelectionChange(event.selection);
});
}
/**
* Returns current editor content.
*/
getSource() {
return this.source;
}
/**
* Registers a callback called with a new cell content whenever it changes.
*/
onChange(callback) {
this._onChange = callback;
}
/**
* Registers a callback called with a new cursor selection whenever it changes.
*/
onCursorSelectionChange(callback) {
this._onCursorSelectionChange = callback;
}
/**
* Registers a callback called whenever the editor loses focus.
*/
onBlur(callback) {
this._onBlur = callback;
}
focus() {
this.editor.focus();
}
blur() {
if (this.editor.hasTextFocus()) {
document.activeElement.blur();
}
}
insert(text) {
const range = this.editor.getSelection();
this.editor
.getModel()
.pushEditOperations([], [{ forceMoveMarkers: true, range, text }]);
}
/**
* Performs necessary cleanup actions.
*/
dispose() {
// Explicitly destroy the editor instance and its text model.
this.editor.dispose();
const model = this.editor.getModel();
if (model) {
model.dispose();
}
}
/**
* Either adds or moves remote user cursor to the new position.
*/
updateUserSelection(client, selection) {
if (this._remoteUserByClientPid[client.pid]) {
this._remoteUserByClientPid[client.pid].update(selection);
} else {
this._remoteUserByClientPid[client.pid] = new RemoteUser(
this.editor,
selection,
client.hex_color,
client.name
);
}
}
/**
* Removes remote user cursor.
*/
removeUserSelection(client) {
if (this._remoteUserByClientPid[client.pid]) {
this._remoteUserByClientPid[client.pid].dispose();
delete this._remoteUserByClientPid[client.pid];
}
}
__mountEditor() {
this.editor = monaco.editor.create(this.container, {
language: this.type,
value: this.source,
scrollbar: {
vertical: "hidden",
alwaysConsumeMouseWheel: false,
},
minimap: {
enabled: false,
},
overviewRulerLanes: 0,
scrollBeyondLastLine: false,
renderIndentGuides: false,
occurrencesHighlight: false,
renderLineHighlight: "none",
theme: "custom",
fontFamily: "JetBrains Mono, Droid Sans Mono, monospace",
fontSize: 14,
tabIndex: -1,
quickSuggestions: false,
tabCompletion: "on",
suggestSelection: "first",
});
this.editor.getModel().updateOptions({
tabSize: 2,
});
this.editor.updateOptions({
autoIndent: true,
tabSize: 2,
formatOnType: true,
});
// Automatically adjust the editor size to fit the container.
const resizeObserver = new ResizeObserver((entries) => {
entries.forEach((entry) => {
// Ignore hidden container.
if (this.container.offsetHeight > 0) {
this.editor.layout();
}
});
});
resizeObserver.observe(this.container);
// Whenever editor content size changes (new line is added/removed)
// update the container height. Thanks to the above observer
// the editor is resized to fill the container.
// Related: https://github.com/microsoft/monaco-editor/issues/794#issuecomment-688959283
this.editor.onDidContentSizeChange(() => {
const contentHeight = this.editor.getContentHeight();
this.container.style.height = `${contentHeight}px`;
});
}
__setupCompletion() {
/**
* Completion happens asynchronously, the flow goes as follows:
*
* * the user opens the completion list, which triggers the global
* completion provider registered in `live_editor/monaco.js`
*
* * the global provider delegates to the cell-specific `__getCompletionItems`
* defined below. That's a little bit hacky, but this way we make
* completion cell-specific
*
* * then `__getCompletionItems` sends a completion request to the LV process
* and gets a unique reference, under which it keeps completion callback
*
* * finally the hook receives the "completion_response" event with completion items,
* it looks up completion callback for the received reference and calls it
* with the received items list
*/
const completionHandlerByRef = {};
this.editor.getModel().__getCompletionItems = (model, position) => {
const line = model.getLineContent(position.lineNumber);
const lineUntilCursor = line.slice(0, position.column - 1);
return new Promise((resolve, reject) => {
this.hook.pushEvent(
"completion_request",
{
hint: lineUntilCursor,
cell_id: this.cellId,
},
({ completion_ref: completionRef }) => {
if (completionRef) {
completionHandlerByRef[completionRef] = (items) => {
const suggestions = completionItemsToSuggestions(items);
resolve({ suggestions });
};
} else {
resolve({ suggestions: [] });
}
}
);
});
};
this.hook.handleEvent(
"completion_response",
({ completion_ref: completionRef, items }) => {
const handler = completionHandlerByRef[completionRef];
if (handler) {
handler(items);
delete completionHandlerByRef[completionRef];
}
}
);
}
__setupFormatting() {
/**
* Similarly to completion, formatting is delegated to the function
* defined below, where we simply communicate with LV to get
* a formatted version of the current editor content.
*/
this.editor.getModel().__getDocumentFormattingEdits = (model) => {
const content = model.getValue();
return new Promise((resolve, reject) => {
this.hook.pushEvent(
"format_code",
{ code: content },
({ code: formatted }) => {
/**
* We use a single edit replacing the whole editor content,
* but the editor itself optimises this into a list of edits
* that produce minimal diff using the Myers string difference.
*
* References:
* * https://github.com/microsoft/vscode/blob/628b4d46357f2420f1dbfcea499f8ff59ee2c251/src/vs/editor/contrib/format/format.ts#L324
* * https://github.com/microsoft/vscode/blob/628b4d46357f2420f1dbfcea499f8ff59ee2c251/src/vs/editor/common/services/editorSimpleWorker.ts#L489
* * https://github.com/microsoft/vscode/blob/628b4d46357f2420f1dbfcea499f8ff59ee2c251/src/vs/base/common/diff/diff.ts#L227-L231
*
* Eventually the editor will received the optimised list of edits,
* which we then convert to Delta and send to the server.
* Consequently, the Delta carries only the minimal formatting diff.
*
* Also, if edits are applied to the editor, either by typing
* or receiving remote changes, the formatting is cancelled.
* In other words the formatting changes are actually applied
* only if the editor stays intact.
*
* References:
* * https://github.com/microsoft/vscode/blob/628b4d46357f2420f1dbfcea499f8ff59ee2c251/src/vs/editor/contrib/format/format.ts#L313
* * https://github.com/microsoft/vscode/blob/628b4d46357f2420f1dbfcea499f8ff59ee2c251/src/vs/editor/browser/core/editorState.ts#L137
* * https://github.com/microsoft/vscode/blob/628b4d46357f2420f1dbfcea499f8ff59ee2c251/src/vs/editor/contrib/format/format.ts#L326
*/
const replaceEdit = {
range: model.getFullModelRange(),
text: formatted,
};
resolve([replaceEdit]);
}
);
});
};
}
}
function completionItemsToSuggestions(items) {
return items.map(parseItem).map((suggestion, index) => ({
...suggestion,
sortText: numberToSortableString(index, items.length),
}));
}
// See `Livebook.Runtime` for completion item definition
function parseItem(item) {
return {
label: item.label,
kind: parseItemKind(item.kind),
detail: item.detail,
documentation: item.documentation && {
value: item.documentation,
isTrusted: true,
},
insertText: item.insert_text,
};
}
function parseItemKind(kind) {
switch (kind) {
case "function":
return monaco.languages.CompletionItemKind.Function;
case "module":
return monaco.languages.CompletionItemKind.Module;
case "type":
return monaco.languages.CompletionItemKind.Class;
case "variable":
return monaco.languages.CompletionItemKind.Variable;
case "field":
return monaco.languages.CompletionItemKind.Field;
default:
return null;
}
}
function numberToSortableString(number, maxNumber) {
return String(number).padStart(maxNumber, "0");
}
export default LiveEditor;