livebook/assets/js/hooks/cell_editor/live_editor.js
2024-01-25 17:05:46 +08:00

553 lines
15 KiB
JavaScript

import {
EditorView,
hoverTooltip,
keymap,
highlightSpecialChars,
drawSelection,
highlightActiveLine,
dropCursor,
rectangularSelection,
crosshairCursor,
lineNumbers,
highlightActiveLineGutter,
} from "@codemirror/view";
import { EditorState } from "@codemirror/state";
import {
indentOnInput,
bracketMatching,
foldGutter,
LanguageDescription,
codeFolding,
} from "@codemirror/language";
import { history } from "@codemirror/commands";
import { highlightSelectionMatches } from "@codemirror/search";
import {
autocompletion,
closeBrackets,
snippetCompletion,
} from "@codemirror/autocomplete";
import { setDiagnostics } from "@codemirror/lint";
import { vscodeKeymap } from "@replit/codemirror-vscode-keymap";
import { vim } from "@replit/codemirror-vim";
import { emacs } from "@replit/codemirror-emacs";
import { collab, deltaToChanges } from "./live_editor/codemirror/collab";
import { collabMarkers } from "./live_editor/codemirror/collab_markers";
import { theme, lightTheme } from "./live_editor/codemirror/theme";
import {
clearDoctests,
updateDoctests,
} from "./live_editor/codemirror/doctests";
import { signature } from "./live_editor/codemirror/signature";
import { formatter } from "./live_editor/codemirror/formatter";
import { replacedSuffixLength } from "../../lib/text_utils";
import { settingsStore } from "../../lib/settings";
import Delta from "../../lib/delta";
import Markdown from "../../lib/markdown";
import { readOnlyHint } from "./live_editor/codemirror/read_only_hint";
import { wait } from "../../lib/utils";
import Emitter from "../../lib/emitter";
import CollabClient from "./live_editor/collab_client";
import { languages } from "./live_editor/codemirror/languages";
import { exitMulticursor } from "./live_editor/codemirror/commands";
import { highlight } from "./live_editor/highlight";
/**
* Mounts cell source editor with real-time collaboration mechanism.
*
* The actual editor must be mounted explicitly by calling either of
* the `mount` or `focus` methods, but it can be done at any point.
* Even when not mounted, the editor consumes collaborative updates
* and invokes change listeners.
*/
export default class LiveEditor {
/** @private */
_onMount = new Emitter();
/**
* Registers a callback called when the editor is mounted in DOM.
*/
onMount = this._onMount.event;
/** @private */
_onChange = new Emitter();
/**
* Registers a callback called with a new cell content whenever it changes.
*/
onChange = this._onChange.event;
/** @private */
_onBlur = new Emitter();
/**
* Registers a callback called whenever the editor loses focus.
*/
onBlur = this._onBlur.event;
/** @private */
_onFocus = new Emitter();
/**
* Registers a callback called whenever the editor gains focus.
*/
onFocus = this._onFocus.event;
constructor(
container,
connection,
source,
revision,
language,
intellisense,
readOnly
) {
this.container = container;
this.source = source;
this.language = language;
this.intellisense = intellisense;
this.readOnly = readOnly;
this.initialWidgets = {};
this.connection = connection;
this.collabClient = new CollabClient(connection, revision);
this.deltaSubscription = this.collabClient.onDelta((delta, info) => {
this.source = delta.applyToString(this.source);
this._onChange.dispatch(this.source);
});
}
/**
* Checks if an editor instance has been mounted in the DOM.
*/
isMounted() {
return !!this.view;
}
/**
* Mounts and configures an editor instance in the DOM.
*/
mount() {
if (this.isMounted()) {
throw new Error("The editor is already mounted");
}
this.mountEditor();
this.setInitialWidgets();
this._onMount.dispatch();
}
/**
* Returns current editor content.
*/
getSource() {
return this.source;
}
/**
* Returns an element closest to the current main cursor position.
*/
getElementAtCursor() {
if (!this.isMounted()) {
return this.container;
}
const { node } = this.view.domAtPos(this.view.state.selection.main.head);
if (node instanceof Element) return node;
return node.parentElement;
}
/**
* Focuses the editor.
*
* Note that this forces the editor to be mounted, if it is not already
* mounted.
*/
focus() {
if (!this.isMounted()) {
this.mount();
}
this.view.focus();
}
/**
* Removes focus from the editor.
*/
blur() {
if (this.isMounted() && this.view.hasFocus) {
this.view.contentDOM.blur();
}
}
/**
* Performs necessary cleanup actions.
*/
destroy() {
if (this.isMounted()) {
this.view.destroy();
}
this.collabClient.destroy();
this.deltaSubscription.destroy();
}
/**
* Either adds or updates doctest indicators.
*/
updateDoctests(doctestReports) {
if (this.isMounted()) {
updateDoctests(this.view, doctestReports);
} else {
this.initialWidgets.doctestReportsByLine =
this.initialWidgets.doctestReportsByLine || {};
for (const report of doctestReports) {
this.initialWidgets.doctestReportsByLine[report.line] = report;
}
}
}
/**
* Removes doctest indicators.
*/
clearDoctests() {
if (this.isMounted()) {
clearDoctests(this.view);
} else {
delete this.initialWidgets.doctestReportsByLine;
}
}
/**
* Sets underline markers for warnings and errors.
*
* Passing an empty list clears all markers.
*/
setCodeMarkers(codeMarkers) {
if (this.isMounted()) {
const doc = this.view.state.doc;
const diagnostics = codeMarkers.map((marker) => {
const line = doc.line(marker.line);
const [, leadingWhitespace, trailingWhitespace] =
line.text.match(/^(\s*).*?(\s*)$/);
const from = line.from + leadingWhitespace.length;
const to = line.to - trailingWhitespace.length;
return {
from,
to,
severity: marker.severity,
message: marker.description,
};
});
this.view.dispatch(setDiagnostics(this.view.state, diagnostics));
} else {
this.initialWidgets.codeMarkers = codeMarkers;
}
}
/** @private */
mountEditor() {
const settings = settingsStore.get();
const formatLineNumber = (number) => number.toString().padStart(3, " ");
const foldGutterMarkerDOM = (open) => {
const node = document.createElement("i");
node.classList.add(
open ? "ri-arrow-down-s-line" : "ri-arrow-right-s-line",
open ? "cm-gutterFoldMarker-open" : null
);
return node;
};
const fontSizeTheme = EditorView.theme({
"&": { fontSize: `${settings.editor_font_size}px` },
});
const lineWrappingEnabled =
this.language === "markdown" && settings.editor_markdown_word_wrap;
const language = LanguageDescription.matchLanguageName(
languages,
this.language,
false
);
const customKeymap = [{ key: "Escape", run: exitMulticursor }];
this.view = new EditorView({
parent: this.container,
doc: this.source,
extensions: [
lineNumbers({ formatNumber: formatLineNumber }),
highlightActiveLine(),
highlightActiveLineGutter(),
highlightSpecialChars(),
highlightSelectionMatches(),
foldGutter({ markerDOM: foldGutterMarkerDOM }),
codeFolding({ placeholderText: "⋯" }),
drawSelection(),
dropCursor(),
rectangularSelection(),
crosshairCursor(),
EditorState.allowMultipleSelections.of(true),
bracketMatching(),
closeBrackets(),
indentOnInput(),
history(),
EditorState.readOnly.of(this.readOnly),
readOnlyHint(),
keymap.of(vscodeKeymap),
keymap.of(customKeymap),
EditorState.tabSize.of(2),
EditorState.lineSeparator.of("\n"),
lineWrappingEnabled ? EditorView.lineWrapping : [],
// We bind tab to actions within the editor, which would trap
// the user if they tabbed into the editor, so we remove it
// from the tab navigation
EditorView.contentAttributes.of({ tabIndex: -1 }),
fontSizeTheme,
settings.editor_theme === "light" ? lightTheme : theme,
collab(this.collabClient),
collabMarkers(this.collabClient),
autocompletion({
activateOnTyping: settings.editor_auto_completion,
defaultKeymap: false,
}),
this.intellisense
? [
autocompletion({ override: [this.completionSource.bind(this)] }),
hoverTooltip(this.docsHoverTooltipSource.bind(this)),
signature(this.signatureSource.bind(this), {
activateOnTyping: settings.editor_auto_signature,
}),
formatter(this.formatterSource.bind(this)),
]
: [],
settings.editor_mode === "vim" ? [vim()] : [],
settings.editor_mode === "emacs" ? [emacs()] : [],
language && language.support,
EditorView.domEventHandlers({
keydown: this.handleEditorKeydown.bind(this),
blur: this.handleEditorBlur.bind(this),
focus: this.handleEditorFocus.bind(this),
}),
],
});
}
/** @private */
handleEditorKeydown(event) {
// We dispatch escape event, but only if it is not consumed by any
// registered handler in the editor, such as closing autocompletion
// or escaping Vim insert mode
if (event.key === "Escape") {
this.container.dispatchEvent(
new CustomEvent("lb:editor_escape", { bubbles: true })
);
}
return false;
}
/** @private */
handleEditorBlur(event) {
if (!this.container.contains(event.relatedTarget)) {
this._onBlur.dispatch();
}
return false;
}
/** @private */
handleEditorFocus(event) {
this._onFocus.dispatch();
return false;
}
/** @private */
completionSource(context) {
const settings = settingsStore.get();
// Trigger completion implicitly only for identifiers and members
const triggerBeforeCursor = context.matchBefore(/[\w?!.]$/);
const lineUntilCursor = context.matchBefore(/^.*/);
if (!triggerBeforeCursor && !context.explicit) {
return null;
}
return this.connection
.intellisenseRequest("completion", {
hint: lineUntilCursor.text,
editor_auto_completion: settings.editor_auto_completion,
})
.then((response) => {
if (response.items.length === 0) return null;
const completions = response.items.map((item, index) => {
const completion = this.completionItemToCompletions(item);
return {
...completion,
// Keep the ordering from the server
boost: 1 - index / response.items.length,
};
});
const replaceLength = replacedSuffixLength(
lineUntilCursor.text,
response.items[0].insert_text
);
return {
from: lineUntilCursor.to - replaceLength,
options: completions,
validFor: /^\w*[!?]?$/,
};
})
.catch(() => null);
}
/** @private */
completionItemToCompletions(item) {
const completion = {
label: item.label,
type: item.kind,
info: (completion) => {
// The info popup is shown automatically, we delay it a bit
// to not distract the user too much as they are typing
return wait(350).then(() => {
const node = document.createElement("div");
if (item.detail) {
const detail = document.createElement("div");
detail.classList.add("cm-completionInfoDetail");
detail.innerHTML = highlight(item.detail, this.language);
node.appendChild(detail);
}
if (item.documentation) {
const docs = document.createElement("div");
docs.classList.add("cm-completionInfoDocs");
docs.classList.add("cm-markdown");
node.appendChild(docs);
new Markdown(docs, item.documentation, {
defaultCodeLanguage: this.language,
});
}
return node;
});
},
};
// Place cursor at the end, if not explicitly specified
const template = item.insert_text.includes("${}")
? item.insert_text
: item.insert_text + "${}";
return snippetCompletion(template, completion);
}
/** @private */
docsHoverTooltipSource(view, pos, side) {
const line = view.state.doc.lineAt(pos);
const lineLength = line.to - line.from;
const text = line.text;
// If we are on the right side of the position, we add one to
// convert it to column
const column = pos - line.from + (side === 1 ? 1 : 0);
if (column < 1 || column > lineLength) return null;
return this.connection
.intellisenseRequest("details", { line: text, column })
.then((response) => {
// Note: the response range is a right-exclusive column range
return {
pos: line.from + response.range.from - 1,
end: line.from + response.range.to - 1,
above: true,
create: (view) => {
const dom = document.createElement("div");
dom.classList.add("cm-hoverDocs");
for (const content of response.contents) {
const item = document.createElement("div");
item.classList.add("cm-hoverDocsContent");
item.classList.add("cm-markdown");
dom.appendChild(item);
new Markdown(item, content, {
defaultCodeLanguage: this.language,
});
}
return { dom };
},
};
})
.catch(() => null);
}
/** @private */
signatureSource({ state, pos }) {
const textUntilCursor = state.doc.sliceString(0, pos);
return this.connection
.intellisenseRequest("signature", {
hint: textUntilCursor,
})
.then((response) => {
return {
activeArgumentIdx: response.active_argument,
items: response.items,
};
})
.catch(() => null);
}
formatterSource(doc) {
return this.connection
.intellisenseRequest("format", { code: doc.toString() })
.then((response) => {
this.setCodeMarkers(response.code_markers);
if (response.delta) {
const delta = Delta.fromCompressed(response.delta);
return deltaToChanges(delta);
} else {
return null;
}
})
.catch(() => null);
}
/** @private */
setInitialWidgets() {
if (this.initialWidgets.doctestReportsByLine) {
const doctestReports = Object.values(
this.initialWidgets.doctestReportsByLine
);
this.updateDoctests(doctestReports);
}
if (this.initialWidgets.codeMarkers) {
this.setCodeMarkers(this.initialWidgets.codeMarkers);
}
this.initialWidgets = {};
}
}