@nteract/monaco-editor
Version:
A React component for the monaco editor, tailored for nteract
607 lines • 28.1 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const monaco = __importStar(require("monaco-editor/esm/vs/editor/editor.api"));
const React = __importStar(require("react"));
const completionItemProvider_1 = require("./completions/completionItemProvider");
const documentUri_1 = require("./documentUri");
const lodash_debounce_1 = __importDefault(require("lodash.debounce"));
const layoutSchedule_1 = require("./layoutSchedule");
const resizeObserver = __importStar(require("./resizeObserver"));
const intersectionObserver = __importStar(require("./intersectionObserver"));
/**
* This adds an additional padded area around the editor for the mouse
* to move around before we decide to hide the popup. This makes the
* transition less erratic and hopefully a smoother experience.
*/
const HOVER_BOUND_DEFAULT_PADDING = 5;
/**
* Creates a MonacoEditor instance
*/
class MonacoEditor extends React.Component {
constructor(props) {
super(props);
this.editorContainerRef = React.createRef();
this.isInViewport = true;
this.deferredLayoutRequest = false;
this.onBlur = this.onBlur.bind(this);
this.onDidChangeModelContent = this.onDidChangeModelContent.bind(this);
this.onFocus = this.onFocus.bind(this);
this.onResize = this.onResize.bind(this);
this.hideAllOtherParameterWidgets = this.hideAllOtherParameterWidgets.bind(this);
this.handleCoordsOutsideWidgetActiveRegion = lodash_debounce_1.default(this.handleCoordsOutsideWidgetActiveRegion.bind(this), 50 // Make sure we rate limit the calls made by mouse movement
);
}
onDidChangeModelContent(e) {
if (this.editor && this.props.onChange) {
this.props.onChange(this.editor.getValue(), e);
}
}
readEditorDomSize() {
var _a;
const container = (_a = this.editor) === null || _a === void 0 ? void 0 : _a.getContainerDomNode();
if (!container) {
return undefined;
}
// use clientHeight and clientWidth from the editor.
return {
width: container.clientWidth,
height: container.clientHeight
};
}
getLayoutDimension() {
var _a, _b;
const dim = this.readEditorDomSize();
// if the container is zero sized, return undefined
if (!dim || dim.width === 0 || dim.height === 0) {
return undefined;
}
if ((_a = this.props.autoFitContentHeight) !== null && _a !== void 0 ? _a : true) {
// auto fit the height to the content
const contentHeight = (_b = this.editor) === null || _b === void 0 ? void 0 : _b.getContentHeight();
if (contentHeight) {
dim.height = contentHeight;
}
}
if (this.props.maxContentHeight) {
dim.height = Math.min(dim.height, this.props.maxContentHeight);
}
return dim;
}
isContainerHidden() {
const container = this.editorContainerRef.current;
return !(container === null || container === void 0 ? void 0 : container.offsetParent) || !(container === null || container === void 0 ? void 0 : container.offsetHeight);
}
/**
* write the layout to the DOM
*/
layout(layout) {
if (!this.editor) {
return;
}
this.editor.layout(layout);
}
/**
* Implementation for IEditor from layoutSchedule, could cause a DOM read operation
*/
shouldLayout() {
return this.props.skipLayoutWhenHidden ? !this.isContainerHidden() : true;
}
requestLayout(dimension) {
if (!this.editor) {
return;
}
// check if the editor is in the viewport first since it doesn't touch the DOM
if (!this.isInViewport) {
this.deferredLayoutDimension = dimension;
this.deferredLayoutRequest = true;
return;
}
// when skipLayoutWhenHidden is true and the editor's parent or ancestor container is hidden,
// we will not layout the editor.
if (!this.shouldLayout()) {
return;
}
if (this.props.batchLayoutChanges === true) {
layoutSchedule_1.scheduleEditorForLayout(this, dimension);
}
else {
if (!dimension) {
dimension = this.getLayoutDimension();
}
if (dimension) {
this.layout(dimension);
}
}
}
onIntersecting(isIntersecting) {
if (this.isInViewport !== isIntersecting) {
this.isInViewport = isIntersecting;
if (this.isInViewport && this.deferredLayoutRequest) {
this.deferredLayoutRequest = false;
this.requestLayout(this.deferredLayoutDimension);
}
}
}
updateIntersectRegistration() {
if (this.props.skipLayoutWhenNotInViewport) {
if (this.editorContainerRef.current && this.intersectObservation === undefined) {
this.isInViewport = false;
this.intersectObservation = intersectionObserver.observe(this, this.editorContainerRef.current);
}
}
else {
if (this.intersectObservation) {
this.intersectObservation();
this.intersectObservation = undefined;
}
// assume all editors are in viewport if skipLayoutWhenNotInViewport is false
this.isInViewport = true;
}
}
componentDidMount() {
if (this.editorContainerRef && this.editorContainerRef.current) {
// Register intersection observer if needed
this.updateIntersectRegistration();
// Register Jupyter completion provider if needed
this.registerCompletionProvider();
// Register document formatter if needed
this.registerDocumentFormatter();
// Use Monaco model uri if provided. Otherwise, create a new model uri using editor id.
const uri = this.props.modelUri ? this.props.modelUri : monaco.Uri.file(this.props.id);
// Only create a new model if it does not exist. For example, when we double click on a markdown cell,
// an editor model is created for it. Once we go back to markdown preview mode that doesn't use the editor,
// double clicking on the markdown cell will again instantiate a monaco editor. In that case, we should
// rebind the previously created editor model for the markdown instead of recreating one. Monaco does not
// allow models to be recreated with the same uri.
let model = monaco.editor.getModel(uri);
if (!model) {
model = monaco.editor.createModel(this.props.value, this.props.language, uri);
}
// Set line endings to \n line feed to be consistent across OS platforms. This will auto-normalize the line
// endings of the current value to use \n and any future values produced by the Monaco editor will use \n.
model.setEOL(monaco.editor.EndOfLineSequence.LF);
// Update Text model options
model.updateOptions({
indentSize: this.props.indentSize,
tabSize: this.props.tabSize
});
// Create Monaco editor backed by a Monaco model.
this.editor = monaco.editor.create(this.editorContainerRef.current, Object.assign(Object.assign({ autoIndent: "advanced",
// Allow editor pop up widgets such as context menus, signature help, hover tips to be able to be
// displayed outside of the editor. Without this, the pop up widgets can be clipped.
fixedOverflowWidgets: true, find: {
addExtraSpaceOnTop: false,
seedSearchStringFromSelection: "always",
autoFindInSelection: "never" // default is "never"
}, language: this.props.language, lineNumbers: this.props.lineNumbers ? "on" : "off", minimap: {
enabled: false
}, model, overviewRulerLanes: 0, padding: {
top: 12,
bottom: 5
}, readOnly: this.props.readOnly,
// Disable highlight current line, too much visual noise with it on.
// VS Code also has it disabled for their notebook experience.
renderLineHighlight: "none",
// Do not include words from the editor into the autocomplete suggestions list
wordBasedSuggestions: false, scrollbar: {
useShadows: false,
verticalHasArrows: false,
horizontalHasArrows: false,
vertical: "hidden",
horizontal: "hidden",
verticalScrollbarSize: 0,
horizontalScrollbarSize: 0,
arrowSize: 30
}, theme: this.props.theme, value: this.props.value, dimension: this.props.initialDimension }, this.props.options), {
// this is required, otherwise the editor will continue to change its size on layout if set to true in options overrides
scrollBeyondLastLine: false }));
// Handle on create events
if (this.props.onDidCreateEditor) {
this.props.onDidCreateEditor(this.editor);
}
// Handle custom keyboard shortcuts
if (this.editor && this.props.shortcutsHandler && this.props.shortcutsOptions) {
this.props.shortcutsHandler(this.editor, this.props.shortcutsOptions);
}
// Handle custom commands
if (this.editor && this.props.commandHandler) {
this.props.commandHandler(this.editor);
}
this.toggleEditorOptions(!!this.props.editorFocused);
if (this.props.editorFocused) {
if (!this.editor.hasWidgetFocus()) {
// Bring browser focus to the editor if text not already in focus
this.editor.focus();
}
this.registerCursorListener();
}
// Adds listeners for undo and redo actions emitted from the toolbar
this.editorContainerRef.current.addEventListener("undo", () => {
if (this.editor) {
this.editor.trigger("undo-event", "undo", {});
}
});
this.editorContainerRef.current.addEventListener("redo", () => {
if (this.editor) {
this.editor.trigger("redo-event", "redo", {});
}
});
// Resize Editor container on content size change
this.editor.onDidContentSizeChange((info) => {
var _a, _b;
if (info.contentHeightChanged && ((_a = this.props.autoFitContentHeight) !== null && _a !== void 0 ? _a : true)) {
const layout = (_b = this.editor) === null || _b === void 0 ? void 0 : _b.getLayoutInfo();
if (layout) {
this.requestLayout({ height: info.contentHeight, width: layout.width });
}
}
});
this.editor.onDidChangeModelContent(this.onDidChangeModelContent);
this.editor.onDidFocusEditorWidget(this.onFocus);
this.editor.onDidBlurEditorWidget(this.onBlur);
this.requestLayout(this.props.initialDimension);
if (this.props.cursorPositionHandler) {
this.props.cursorPositionHandler(this.editor, this.props);
}
if (this.editor) {
this.mouseMoveListener = this.editor.onMouseMove((e) => {
var _a, _b, _c, _d;
this.handleCoordsOutsideWidgetActiveRegion((_b = (_a = e.event) === null || _a === void 0 ? void 0 : _a.pos) === null || _b === void 0 ? void 0 : _b.x, (_d = (_c = e.event) === null || _c === void 0 ? void 0 : _c.pos) === null || _d === void 0 ? void 0 : _d.y);
});
}
// Adds listener under the resize window event which calls the resize method
resizeObserver.observe(this, this.editorContainerRef.current);
}
}
/**
* Tells editor to check the surrounding container size and resize itself appropriately
*/
onResize() {
if (this.props.shouldUpdateLayoutWhenNotFocused) {
this.requestLayout();
}
else if (this.editor && this.props.editorFocused) {
// We call layout only for the focussed editor and resize other instances using CSS
this.requestLayout();
}
}
componentDidUpdate(prevProps) {
var _a;
if (!this.editor) {
return;
}
this.updateIntersectRegistration();
const { value, language, contentRef, id, editorFocused, theme } = this.props;
if (this.props.cursorPositionHandler) {
this.props.cursorPositionHandler(this.editor, this.props);
}
// Handle custom commands
if (this.editor && this.props.commandHandler) {
this.props.commandHandler(this.editor);
}
// Ensures that the source contents of the editor (value) is consistent with the state of the editor
// and the value has actually changed.
if (prevProps.value !== this.props.value && this.editor.getValue() !== this.props.value) {
this.editor.setValue(this.props.value);
}
completionItemProvider_1.completionProvider.setChannels(this.props.channels);
// Register Jupyter completion provider if needed
this.registerCompletionProvider();
// Apply new model to the editor when the language is changed.
const model = this.editor.getModel();
if (model && language && model.getLanguageId() !== language) {
// Get a reference to the current editor
const editor = this.editor;
const newUri = documentUri_1.DocumentUri.createCellUri(contentRef, id, language);
if (!monaco.editor.getModel(newUri)) {
// Save the cursor position before we set new model.
const position = editor.getPosition();
// Set new model targeting the changed language.
// Note the new model should be set in a synchronous manner, if we do it asynchronously (e.g. in a setTimeout callback),
// there could be subsequent value changes coming up modifying the old model and the new one is still with the old value.
// Set line endings to \n line feed to be consistent across OS platforms. This will auto-normalize the line
// endings of the current value to use \n and any future values produced by the Monaco editor will use \n.
const newModel = monaco.editor.createModel(value, language, newUri);
newModel.setEOL(monaco.editor.EndOfLineSequence.LF);
editor.setModel(newModel);
// We need to dispose of the old model in a separate event. We cannot dispose of the model within the
// componentDidUpdate method or else the editor will throw an exception. Zero in the timeout field
// means execute immediately but in a seperate next event.
setTimeout(() => {
// Dispose the old model
model.dispose();
}, 0);
// Restore cursor position to new model.
if (position) {
editor.setPosition(position);
}
// Set focus
if (editorFocused && !editor.hasWidgetFocus()) {
editor.focus();
}
}
}
const monacoUpdateOptions = {
readOnly: this.props.readOnly
};
if (theme) {
monacoUpdateOptions.theme = theme;
}
this.editor.updateOptions(monacoUpdateOptions);
// In the multi-tabs scenario, when the notebook is hidden by setting "display:none",
// Any state update propagated here would cause a UI re-layout, monaco-editor will then recalculate
// and set its height to 5px.
// To work around that issue, we skip updating the UI when paraent element's offsetParent is null (which
// indicate an ancient element is hidden by display set to none)
// We may revisit this when we get to refactor for multi-notebooks.
if (!((_a = this.editorContainerRef.current) === null || _a === void 0 ? void 0 : _a.offsetParent)) {
return;
}
// Set focus
if (editorFocused && !this.editor.hasWidgetFocus()) {
this.editor.focus();
}
// Tells the editor pane to check if its container has changed size and fill appropriately
this.requestLayout();
}
componentWillUnmount() {
if (this.editor) {
try {
const model = this.editor.getModel();
// Remove the resize listener
if (this.editorContainerRef.current) {
resizeObserver.unobserve(this.editorContainerRef.current);
}
if (this.intersectObservation) {
this.intersectObservation();
this.intersectObservation = undefined;
}
if (model) {
model.dispose();
}
this.editor.dispose();
this.editor = undefined;
}
catch (err) {
// tslint:disable-next-line
console.error(`Error occurs in disposing editor: ${JSON.stringify(err)}`);
}
}
if (this.mouseMoveListener) {
this.mouseMoveListener.dispose();
}
}
render() {
return (React.createElement("div", { className: "monaco-container" },
React.createElement("div", { ref: this.editorContainerRef, id: `editor-${this.props.id}` })));
}
/**
* Register default kernel-based completion provider.
* @param language Language
*/
registerDefaultCompletionProvider(language) {
// onLanguage event is emitted only once per language when language is first time needed.
monaco.languages.onLanguage(language, () => {
monaco.languages.registerCompletionItemProvider(language, completionItemProvider_1.completionProvider);
});
}
onFocus() {
if (this.props.onFocusChange) {
this.props.onFocusChange(true);
}
this.toggleEditorOptions(true);
this.registerCursorListener();
}
onBlur() {
if (this.props.onFocusChange) {
this.props.onFocusChange(false);
}
this.toggleEditorOptions(false);
this.unregisterCursorListener();
// When editor loses focus, hide parameter widgets (if any currently displayed).
this.hideParameterWidget();
}
registerCursorListener() {
if (this.editor && this.props.onCursorPositionChange) {
const selection = this.editor.getSelection();
this.props.onCursorPositionChange(selection);
if (!this.cursorPositionListener) {
this.cursorPositionListener = this.editor.onDidChangeCursorSelection((event) => this.props.onCursorPositionChange(event.selection));
}
}
}
unregisterCursorListener() {
if (this.cursorPositionListener) {
this.cursorPositionListener.dispose();
this.cursorPositionListener = undefined;
}
}
/**
* Toggle editor options based on if the editor is in active state (i.e. focused).
* When the editor is not active, we want to deactivate some of the visual noise.
* @param isActive Whether editor is active.
*/
toggleEditorOptions(isActive) {
if (this.editor) {
this.editor.updateOptions({
matchBrackets: isActive ? "always" : "never",
occurrencesHighlight: isActive,
guides: {
indentation: isActive
}
});
}
}
/**
* Register language features for target language. Call before setting language type to model.
*/
registerCompletionProvider() {
const { enableCompletion, language, onRegisterCompletionProvider, shouldRegisterDefaultCompletion } = this.props;
if (enableCompletion && language) {
if (onRegisterCompletionProvider) {
onRegisterCompletionProvider(language);
}
else if (shouldRegisterDefaultCompletion) {
this.registerDefaultCompletionProvider(language);
}
}
}
registerDocumentFormatter() {
const { enableFormatting, language, onRegisterDocumentFormattingEditProvider } = this.props;
if (enableFormatting && language) {
if (onRegisterDocumentFormattingEditProvider) {
onRegisterDocumentFormattingEditProvider(language);
}
}
}
/**
* This will hide the parameter widget if the user is not hovering over
* the parameter widget for this monaco editor.
*
* Notes: See issue https://github.com/microsoft/vscode-python/issues/7851 for further info.
* Hide the parameter widget if the following conditions have been met:
* - Editor doesn't have focus
* - Mouse is not over (hovering) the parameter widget
*
* This method is only used for blurring at the moment given that parameter widgets from
* other cells are hidden by mouse move events.
*
* @private
* @returns
* @memberof MonacoEditor
*/
hideParameterWidget() {
if (!this.editor || !this.editor.getDomNode() || !this.editorContainerRef.current) {
return;
}
// Find all elements that the user is hovering over.
// It's possible the parameter widget is one of them.
const hoverElements = Array.prototype.slice.call(document.querySelectorAll(":hover"));
// These are the classes that will appear on a parameter widget when they are visible.
const parameterWidgetClasses = ["editor-widget", "parameter-hints-widget", "visible"];
// Find the parameter widget the user is currently hovering over.
let isParameterWidgetHovered = hoverElements.find((item) => {
var _a;
if (typeof item.className !== "string") {
return false;
}
// Check if user is hovering over a parameter widget.
const classes = item.className.split(" ");
if (!parameterWidgetClasses.every((cls) => classes.indexOf(cls) >= 0)) {
// Not all classes required in a parameter hint widget are in this element.
// Hence this is not a parameter widget.
return false;
}
// Ok, this element that the user is hovering over is a parameter widget.
// Next, check whether this parameter widget belongs to this monaco editor.
// We have a list of parameter widgets that belong to this editor, hence a simple lookup.
return (_a = this.editorContainerRef.current) === null || _a === void 0 ? void 0 : _a.contains(item);
});
// If the parameter widget is being hovered, don't hide it.
if (isParameterWidgetHovered) {
return;
}
// If the editor has focus, don't hide the parameter widget.
// This is the default behavior. Let the user hit `Escape` or click somewhere
// to forcefully hide the parameter widget.
if (this.editor.hasWidgetFocus()) {
return;
}
// If we got here, then the user is not hovering over the parameter widgets.
// & the editor doesn't have focus.
// However some of the parameter widgets associated with this monaco editor are visible.
// We need to hide them.
// Solution: Hide the widgets manually.
this.hideWidgets(this.editorContainerRef.current, [".parameter-hints-widget"]);
}
/**
* Hides widgets such as parameters and hover, that belong to a given parent HTML element.
*
* @private
* @param {HTMLDivElement} widgetParent
* @param {string[]} selectors
* @memberof MonacoEditor
*/
hideWidgets(widgetParent, selectors) {
for (const selector of selectors) {
for (const widget of Array.from(widgetParent.querySelectorAll(selector))) {
widget.setAttribute("class", widget.className
.split(" ")
.filter((cls) => cls !== "visible")
.join(" "));
if (widget.style.visibility !== "hidden") {
widget.style.visibility = "hidden";
}
}
}
}
/**
* Hides the parameters widgets related to other monaco editors.
* Use this to ensure we only display parameters widgets for current editor (by hiding others).
*
* @private
* @returns
* @memberof MonacoEditor
*/
hideAllOtherParameterWidgets() {
if (!this.editorContainerRef.current) {
return;
}
const widgetParents = Array.prototype.slice.call(document.querySelectorAll("div.monaco-container"));
widgetParents
.filter((widgetParent) => { var _a; return widgetParent !== ((_a = this.editorContainerRef.current) === null || _a === void 0 ? void 0 : _a.parentElement); })
.forEach((widgetParent) => this.hideWidgets(widgetParent, [".parameter-hints-widget"]));
}
/**
* Return true if (x,y) coordinates overlap with an element's bounding rect.
* @param {HTMLDivElement} element
* @param {number} x
* @param {number} y
* @param {number} padding
*/
coordsInsideElement(element, x, y, padding = HOVER_BOUND_DEFAULT_PADDING) {
if (!element)
return false;
const clientRect = element.getBoundingClientRect();
return (x >= clientRect.left - padding &&
x <= clientRect.right + padding &&
y >= clientRect.top - padding &&
y <= clientRect.bottom + padding);
}
/**
* Hide all other widgets belonging to other cells only if the currently active
* parameter widget (at most one) is being hovered by the user.
* @param {number} x
* @param {number} y
*/
handleCoordsOutsideWidgetActiveRegion(x, y) {
let widget = document.querySelector(".parameter-hints-widget");
if (widget != null && !this.coordsInsideElement(widget, x, y)) {
this.hideAllOtherParameterWidgets();
}
}
}
exports.default = MonacoEditor;
//# sourceMappingURL=MonacoEditor.js.map