mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-09-17 02:14:58 +08:00
553 lines
15 KiB
JavaScript
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 = {};
|
|
}
|
|
}
|