UNPKG

@nteract/monaco-editor

Version:

A React component for the monaco editor, tailored for nteract

607 lines 28.1 kB
"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