livebook/assets/js/hooks/cell_editor/live_editor.js
2023-01-10 20:30:04 +01:00

644 lines
19 KiB
JavaScript

import renderMathInElement from "katex/contrib/auto-render";
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";
import { replacedSuffixLength } from "../../lib/text_utils";
import { settingsStore } from "../../lib/settings";
/**
* Mounts cell source editor with real-time collaboration mechanism.
*/
class LiveEditor {
constructor(
hook,
container,
cellId,
tag,
source,
revision,
language,
intellisense,
readOnly
) {
this.hook = hook;
this.container = container;
this.cellId = cellId;
this.source = source;
this.language = language;
this.intellisense = intellisense;
this.readOnly = readOnly;
this._onMount = [];
this._onChange = [];
this._onBlur = [];
this._onCursorSelectionChange = [];
this._remoteUserByClientId = {};
const serverAdapter = new HookServerAdapter(hook, cellId, tag);
this.editorClient = new EditorClient(serverAdapter, revision);
this.editorClient.onDelta((delta) => {
this.source = delta.applyToString(this.source);
this._onChange.forEach((callback) => callback(this.source));
});
}
/**
* Checks if an editor instance has been mounted in the DOM.
*/
isMounted() {
return !!this.editor;
}
/**
* Mounts and configures an editor instance in the DOM.
*/
mount() {
if (this.isMounted()) {
throw new Error("The editor is already mounted");
}
this._mountEditor();
if (this.intellisense) {
this._setupIntellisense();
}
this.editorClient.setEditorAdapter(new MonacoEditorAdapter(this.editor));
this.editor.onDidFocusEditorWidget(() => {
this.editor.updateOptions({ matchBrackets: "always" });
});
this.editor.onDidBlurEditorWidget(() => {
this.editor.updateOptions({ matchBrackets: "never" });
this._onBlur.forEach((callback) => callback());
});
this.editor.onDidChangeCursorSelection((event) => {
this._onCursorSelectionChange.forEach((callback) =>
callback(event.selection)
);
});
this._onMount.forEach((callback) => callback());
}
_ensureMounted() {
if (!this.isMounted()) {
this.mount();
}
}
/**
* Returns current editor content.
*/
getSource() {
return this.source;
}
/**
* Registers a callback called with the editor is mounted in DOM.
*/
onMount(callback) {
this._onMount.push(callback);
}
/**
* Registers a callback called with a new cell content whenever it changes.
*/
onChange(callback) {
this._onChange.push(callback);
}
/**
* Registers a callback called with a new cursor selection whenever it changes.
*/
onCursorSelectionChange(callback) {
this._onCursorSelectionChange.push(callback);
}
/**
* Registers a callback called whenever the editor loses focus.
*/
onBlur(callback) {
this._onBlur.push(callback);
}
focus() {
this._ensureMounted();
this.editor.focus();
}
blur() {
this._ensureMounted();
if (this.editor.hasTextFocus()) {
document.activeElement.blur();
}
}
insert(text) {
this._ensureMounted();
const range = this.editor.getSelection();
this.editor
.getModel()
.pushEditOperations([], [{ forceMoveMarkers: true, range, text }]);
}
/**
* Performs necessary cleanup actions.
*/
dispose() {
if (this.isMounted()) {
// 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) {
this._ensureMounted();
if (this._remoteUserByClientId[client.id]) {
this._remoteUserByClientId[client.id].update(selection);
} else {
this._remoteUserByClientId[client.id] = new RemoteUser(
this.editor,
selection,
client.hex_color,
client.name
);
}
}
/**
* Removes remote user cursor.
*/
removeUserSelection(client) {
this._ensureMounted();
if (this._remoteUserByClientId[client.id]) {
this._remoteUserByClientId[client.id].dispose();
delete this._remoteUserByClientId[client.id];
}
}
/**
* Adds an underline marker for the given syntax error.
*
* To clear an existing marker `null` error is also supported.
*/
setCodeErrorMarker(error) {
this._ensureMounted();
const owner = "livebook.error.syntax";
if (error) {
const line = this.editor.getModel().getLineContent(error.line);
const [, leadingWhitespace, trailingWhitespace] =
line.match(/^(\s*).*?(\s*)$/);
monaco.editor.setModelMarkers(this.editor.getModel(), owner, [
{
startLineNumber: error.line,
startColumn: leadingWhitespace.length + 1,
endLineNumber: error.line,
endColumn: line.length + 1 - trailingWhitespace.length,
message: error.description,
severity: monaco.MarkerSeverity.Error,
},
]);
} else {
monaco.editor.setModelMarkers(this.editor.getModel(), owner, []);
}
}
_mountEditor() {
const settings = settingsStore.get();
this.editor = monaco.editor.create(this.container, {
language: this.language,
value: this.source,
readOnly: this.readOnly,
scrollbar: {
vertical: "hidden",
alwaysConsumeMouseWheel: false,
},
minimap: {
enabled: false,
},
overviewRulerLanes: 0,
scrollBeyondLastLine: false,
guides: {
indentation: false,
},
occurrencesHighlight: false,
renderLineHighlight: "none",
theme: settings.editor_theme,
fontFamily: "JetBrains Mono, Droid Sans Mono, monospace",
fontSize: settings.editor_font_size,
tabIndex: -1,
tabSize: 2,
autoIndent: true,
formatOnType: true,
formatOnPaste: true,
quickSuggestions: this.intellisense && settings.editor_auto_completion,
tabCompletion: "on",
suggestSelection: "first",
// For Elixir word suggestions are confusing at times.
// For example given `defmodule<CURSOR> Foo do`, if the
// user opens completion list and then jumps to the end
// of the line we would get "defmodule" as a word completion.
wordBasedSuggestions: !this.intellisense,
parameterHints: this.intellisense && settings.editor_auto_signature,
wordWrap:
this.language === "markdown" && settings.editor_markdown_word_wrap
? "on"
: "off",
});
this.editor.addAction({
contextMenuGroupId: "word-wrapping",
id: "enable-word-wrapping",
label: "Enable word wrapping",
precondition: "config.editor.wordWrap == off",
keybindings: [monaco.KeyMod.Alt | monaco.KeyCode.KeyZ],
run: (editor) => editor.updateOptions({ wordWrap: "on" }),
});
this.editor.addAction({
contextMenuGroupId: "word-wrapping",
id: "disable-word-wrapping",
label: "Disable word wrapping",
precondition: "config.editor.wordWrap == on",
keybindings: [monaco.KeyMod.Alt | monaco.KeyCode.KeyZ],
run: (editor) => editor.updateOptions({ wordWrap: "off" }),
});
// 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`;
});
/* Overrides */
// Move the command palette widget to overflowing widgets container,
// so that it's visible on small editors.
// See: https://github.com/microsoft/monaco-editor/issues/70
const commandPaletteNode = this.editor.getContribution(
"editor.controller.quickInput"
).widget.domNode;
commandPaletteNode.remove();
this.editor._modelData.view._contentWidgets.overflowingContentWidgetsDomNode.domNode.appendChild(
commandPaletteNode
);
}
/**
* Defines cell-specific providers for various editor features.
*/
_setupIntellisense() {
const settings = settingsStore.get();
this.handlerByRef = {};
/**
* Intellisense requests such as completion or formatting are
* handled asynchronously by the runtime.
*
* As an example, let's go through the steps for completion:
*
* * 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 "intellisense_response" event with completion
* response, it looks up completion callback for the received reference and calls
* it with the response, which finally returns the completion items to the editor
*/
this.editor.getModel().__getCompletionItems__ = (model, position) => {
const line = model.getLineContent(position.lineNumber);
const lineUntilCursor = line.slice(0, position.column - 1);
return this._asyncIntellisenseRequest("completion", {
hint: lineUntilCursor,
editor_auto_completion: settings.editor_auto_completion,
})
.then((response) => {
const suggestions = completionItemsToSuggestions(
response.items,
settings
).map((suggestion) => {
const replaceLength = replacedSuffixLength(
lineUntilCursor,
suggestion.insertText
);
const range = new monaco.Range(
position.lineNumber,
position.column - replaceLength,
position.lineNumber,
position.column
);
return { ...suggestion, range };
});
return { suggestions };
})
.catch(() => null);
};
this.editor.getModel().__getHover__ = (model, position) => {
// On the first hover, we setup a listener to postprocess hover
// content with KaTeX. Prior to that, the hover element is not
// in the DOM
this.hoverContentProcessed = false;
if (!this.hoverContentEl) {
this.hoverContentEl = this.container.querySelector(
".monaco-hover-content"
);
if (this.hoverContentEl) {
new MutationObserver((event) => {
// We mutate the DOM, so we use a flag to ignore events
// that we triggered ourselves
if (!this.hoverContentProcessed) {
renderMathInElement(this.hoverContentEl, {
delimiters: [
{ left: "$$", right: "$$", display: true },
{ left: "$", right: "$", display: false },
],
throwOnError: false,
});
this.hoverContentProcessed = true;
}
}).observe(this.hoverContentEl, { childList: true });
} else {
console.warn(
"Could not find an element matching .monaco-hover-content"
);
}
}
const line = model.getLineContent(position.lineNumber);
const column = position.column;
return this._asyncIntellisenseRequest("details", { line, column })
.then((response) => {
const contents = response.contents.map((content) => ({
value: content,
isTrusted: true,
}));
const range = new monaco.Range(
position.lineNumber,
response.range.from,
position.lineNumber,
response.range.to
);
return { contents, range };
})
.catch(() => null);
};
const signatureCache = {
codeUntilLastStop: null,
response: null,
};
this.editor.getModel().__getSignatureHelp__ = (model, position) => {
const lines = model.getLinesContent();
const lineIdx = position.lineNumber - 1;
const prevLines = lines.slice(0, lineIdx);
const lineUntilCursor = lines[lineIdx].slice(0, position.column - 1);
const codeUntilCursor = [...prevLines, lineUntilCursor].join("\n");
const codeUntilLastStop = codeUntilCursor
// Remove trailing characters that don't affect the signature
.replace(/[^(),\s]*?$/, "")
// Remove whitespace before delimiter
.replace(/([(),])\s*$/, "$1");
// Cache subsequent requests for the same prefix, so that we don't
// make unnecessary requests
if (codeUntilLastStop === signatureCache.codeUntilLastStop) {
return {
value: signatureResponseToSignatureHelp(signatureCache.response),
dispose: () => {},
};
}
return this._asyncIntellisenseRequest("signature", {
hint: codeUntilCursor,
})
.then((response) => {
signatureCache.response = response;
signatureCache.codeUntilLastStop = codeUntilLastStop;
return {
value: signatureResponseToSignatureHelp(response),
dispose: () => {},
};
})
.catch(() => null);
};
this.editor.getModel().__getDocumentFormattingEdits__ = (model) => {
const content = model.getValue();
return this._asyncIntellisenseRequest("format", { code: content })
.then((response) => {
this.setCodeErrorMarker(response.code_error);
if (response.code) {
/**
* 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: response.code,
};
return [replaceEdit];
} else {
return [];
}
})
.catch(() => null);
};
this.hook.handleEvent("intellisense_response", ({ ref, response }) => {
const handler = this.handlerByRef[ref];
if (handler) {
handler(response);
delete this.handlerByRef[ref];
}
});
}
/**
* Pushes an intellisense request.
*
* The returned promise is either resolved with a valid
* response or rejected with null.
*/
_asyncIntellisenseRequest(type, props) {
return new Promise((resolve, reject) => {
this.hook.pushEvent(
"intellisense_request",
{ cell_id: this.cellId, type, ...props },
({ ref }) => {
if (ref) {
this.handlerByRef[ref] = (response) => {
if (response) {
resolve(response);
} else {
reject(null);
}
};
} else {
reject(null);
}
}
);
});
}
}
function completionItemsToSuggestions(items, settings) {
return items
.map((item) => parseItem(item, settings))
.map((suggestion, index) => ({
...suggestion,
sortText: numberToSortableString(index, items.length),
}));
}
// See `Livebook.Runtime` for completion item definition
function parseItem(item, settings) {
return {
label: item.label,
kind: parseItemKind(item.kind),
detail: item.detail,
documentation: item.documentation && {
value: item.documentation,
isTrusted: true,
},
insertText: item.insert_text,
insertTextRules:
monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
command: settings.editor_auto_signature
? {
title: "Trigger Parameter Hint",
id: "editor.action.triggerParameterHints",
}
: null,
};
}
function parseItemKind(kind) {
switch (kind) {
case "function":
return monaco.languages.CompletionItemKind.Function;
case "module":
return monaco.languages.CompletionItemKind.Module;
case "struct":
return monaco.languages.CompletionItemKind.Struct;
case "interface":
return monaco.languages.CompletionItemKind.Interface;
case "type":
return monaco.languages.CompletionItemKind.Class;
case "variable":
return monaco.languages.CompletionItemKind.Variable;
case "field":
return monaco.languages.CompletionItemKind.Field;
case "keyword":
return monaco.languages.CompletionItemKind.Keyword;
default:
return null;
}
}
function numberToSortableString(number, maxNumber) {
return String(number).padStart(maxNumber, "0");
}
function signatureResponseToSignatureHelp(response) {
return {
activeSignature: 0,
activeParameter: response.active_argument,
signatures: response.signature_items.map((signature_item) => ({
label: signature_item.signature,
parameters: signature_item.arguments.map((argument) => ({
label: argument,
})),
documentation: null,
})),
};
}
export default LiveEditor;