diff --git a/src/Explorer/Notebook/MonacoEditor/MonacoEditor.tsx b/src/Explorer/Notebook/MonacoEditor/MonacoEditor.tsx new file mode 100644 index 000000000..7a561b513 --- /dev/null +++ b/src/Explorer/Notebook/MonacoEditor/MonacoEditor.tsx @@ -0,0 +1,461 @@ +import { Channels } from "@nteract/messaging"; +// import * as monaco from "./monaco"; +import * as monaco from "monaco-editor/esm/vs/editor/editor.api"; +import * as React from "react"; +// import { completionProvider } from "./completions/completionItemProvider"; +import { AppState, ContentRef } from "@nteract/core"; +import { connect } from "react-redux"; +import "./styles.css"; +import { LightThemeName, HCLightThemeName, DarkThemeName } from "./theme"; +// import { logger } from "src/common/localLogger"; +import { getCellMonacoLanguage } from "./selectors"; +// import { DocumentUri } from "./documentUri"; + +export type IModelContentChangedEvent = monaco.editor.IModelContentChangedEvent; + +/** + * Initial props for Monaco received from agnostic component + */ +export interface IMonacoProps { + id: string; + contentRef: ContentRef; + modelUri?: monaco.Uri; + theme: monaco.editor.IStandaloneThemeData | monaco.editor.BuiltinTheme | string; + cellLanguageOverride?: string; + notebookLanguageOverride?: string; + readOnly?: boolean; + channels: Channels | undefined; + enableCompletion: boolean; + shouldRegisterDefaultCompletion?: boolean; + onChange: (value: string, event?: any) => void; + onFocusChange: (focus: boolean) => void; + onCursorPositionChange?: (selection: monaco.ISelection | null) => void; + onRegisterCompletionProvider?: (languageId: string) => void; + value: string; + editorFocused: boolean; + lineNumbers: boolean; + + /** set height of editor to fit the specified number of lines in display */ + numberOfLines?: number; + + options?: monaco.editor.IEditorOptions; +} + +/** + * Monaco specific props derived from State + */ +interface IMonacoStateProps { + language: string; +} + +// Cache the custom theme data to avoid repeatly defining the custom theme +let customThemeData: monaco.editor.IStandaloneThemeData; + +function getMonacoTheme(theme: monaco.editor.IStandaloneThemeData | monaco.editor.BuiltinTheme | string) { + if (typeof theme === "string") { + switch (theme) { + case "vs-dark": + return DarkThemeName; + case "hc-black": + return "hc-black"; + case "vs": + return LightThemeName; + case "hc-light": + return HCLightThemeName; + default: + return LightThemeName; + } + } else if (theme === undefined || typeof theme === "undefined") { + return LightThemeName; + } else { + const themeName = "custom-vs"; + + // Skip redefining the same custom theme if it is the same theme data. + if (customThemeData !== theme) { + monaco.editor.defineTheme(themeName, theme); + customThemeData = theme; + } + + return themeName; + } +} + +const makeMapStateToProps = (initialState: AppState, initialProps: IMonacoProps) => { + const { id, contentRef } = initialProps; + function mapStateToProps(state: any, ownProps: IMonacoProps & IMonacoStateProps) { + return { + language: getCellMonacoLanguage( + state, + contentRef, + id, + ownProps.cellLanguageOverride, + ownProps.notebookLanguageOverride + ) + }; + } + return mapStateToProps; +}; + +/** + * Creates a MonacoEditor instance within the MonacoContainer div + */ +export class MonacoEditor extends React.Component { + editor?: monaco.editor.IStandaloneCodeEditor; + editorContainerRef = React.createRef(); + contentHeight?: number; + private cursorPositionListener?: monaco.IDisposable; + + constructor(props: IMonacoProps & IMonacoStateProps) { + super(props); + this.onFocus = this.onFocus.bind(this); + this.onBlur = this.onBlur.bind(this); + this.calculateHeight = this.calculateHeight.bind(this); + } + + onDidChangeModelContent(e: monaco.editor.IModelContentChangedEvent) { + if (this.editor) { + if (this.props.onChange) { + this.props.onChange(this.editor.getValue(), e); + } + + this.calculateHeight(); + } + } + + /** + * Adjust the height of editor + * + * @remarks + * The way to determine how many lines we should display in editor: + * If numberOfLines is not set or set to 0, we adjust the height to fit the content + * If numberOfLines is specified we respect that setting + */ + calculateHeight() { + // Make sure we have an editor + if (!this.editor) { + return; + } + + // Make sure we have a model + const model = this.editor.getModel(); + if (!model) { + return; + } + + if (this.editorContainerRef && this.editorContainerRef.current) { + const expectedLines = this.props.numberOfLines || model.getLineCount(); + // The find & replace menu takes up 2 lines, that is why 2 line is set as the minimum number of lines + // TODO: we should either disable the find/replace menu or auto expand the editor when find/replace is triggerred. + const finalizedLines = Math.max(expectedLines, 1) + 1; + const lineHeight = this.editor.getConfiguration().lineHeight; + + const contentHeight = finalizedLines * lineHeight; + if (this.contentHeight !== contentHeight) { + this.editorContainerRef.current.style.height = contentHeight + "px"; + this.editor.layout(); + this.contentHeight = contentHeight; + } + } + } + + componentDidMount() { + if (this.editorContainerRef && this.editorContainerRef.current) { + // Register Jupyter completion provider if needed + this.registerCompletionProvider(); + + // 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); + } + + // Create Monaco editor backed by a Monaco model. + this.editor = monaco.editor.create(this.editorContainerRef.current, { + // Following are the default settings + minimap: { + enabled: false + }, + autoIndent: true, + overviewRulerLanes: 1, + scrollbar: { + useShadows: false, + verticalHasArrows: false, + horizontalHasArrows: false, + vertical: "hidden", + horizontal: "hidden", + verticalScrollbarSize: 0, + horizontalScrollbarSize: 0, + arrowSize: 30 + }, + scrollBeyondLastLine: false, + find: { + // TODO Need this? + // addExtraSpaceOnTop: false, // pops the editor out of alignment if turned on + seedSearchStringFromSelection: true, // default is true + autoFindInSelection: false // default is false + }, + // Disable highlight current line, too much visual noise with it on. + // VS Code also has it disabled for their notebook experience. + renderLineHighlight: "none", + + // 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, + + // Apply custom settings from configuration + ...this.props.options, + + // Apply specific settings passed-in as direct props + model, + value: this.props.value, + language: this.props.language, + readOnly: this.props.readOnly, + lineNumbers: this.props.lineNumbers ? "on" : "off", + theme: getMonacoTheme(this.props.theme) + }); + + this.addEditorTopMargin(); + + // Ignore Ctrl + Enter + // tslint:disable-next-line no-bitwise + this.editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, () => { + // Do nothing. This is handled elsewhere, we just don't want the editor to put the newline. + }, undefined); + // TODO Add right context + + this.toggleEditorOptions(this.props.editorFocused); + + if (this.props.editorFocused) { + if (!this.editor.hasTextFocus()) { + // Bring browser focus to the editor if text not already in focus + this.editor.focus(); + } + this.registerCursorListener(); + } + + // TODO: Need to remove the event listener when the editor is disposed, or we have a memory leak here. + // The same applies to the other event listeners below + // Adds listener under the resize window event which calls the resize method + window.addEventListener("resize", this.resize.bind(this)); + + // 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", {}); + } + }); + + this.editor.onDidChangeModelContent(this.onDidChangeModelContent.bind(this)); + this.editor.onDidFocusEditorText(this.onFocus); + this.editor.onDidBlurEditorText(this.onBlur); + this.calculateHeight(); + + // FIXME: This might need further investigation as the props value should be respected in construction + // The following is a mitigation measure till that time + // Ensures that the source contents of the editor (value) is consistent with the state of the editor + this.editor.setValue(this.props.value); + } + } + + addEditorTopMargin() { + if (this.editor) { + // Monaco editor doesn't have margins + // https://github.com/notable/notable/issues/551 + // This is a workaround to add an editor area 12px padding at the top + // so that cursors rendered by collab decorators could be visible without being cut. + this.editor.changeViewZones((changeAccessor) => { + const domNode = document.createElement("div"); + changeAccessor.addZone({ + afterLineNumber: 0, + heightInPx: 12, + domNode + }); + }); + } + } + + /** + * Tells editor to check the surrounding container size and resize itself appropriately + */ + resize() { + if (this.editor && this.props.editorFocused) { + this.editor.layout(); + } + } + + componentDidUpdate() { + if (!this.editor) { + return; + } + + const { value, /* channels, language, contentRef, id, */ editorFocused, theme } = this.props; + + // Ensures that the source contents of the editor (value) is consistent with the state of the editor + if (this.editor.getValue() !== value) { + this.editor.setValue(value); + } + + // completionProvider.setChannels(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.getModeId() !== language) { + const newUri = DocumentUri.createCellUri(contentRef, id, language); + if (!monaco.editor.getModel(newUri)) { + // Save the cursor position before we set new model. + const position = this.editor.getPosition(); + + // Set new model targeting the changed language. + this.editor.setModel(monaco.editor.createModel(value, language, newUri)); + this.addEditorTopMargin(); + + // Restore cursor position to new model. + if (position) { + this.editor.setPosition(position); + } + + // Dispose of the old model in a seperate 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(() => model.dispose(), 0); + } + } + */ + + if (theme) { + monaco.editor.setTheme(getMonacoTheme(theme)); + } + + // 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 (!this.editorContainerRef.current?.offsetParent) { + return; + } + + // Set focus + if (editorFocused && !this.editor.hasTextFocus()) { + this.editor.focus(); + } + + // Tells the editor pane to check if its container has changed size and fill appropriately + this.editor.layout(); + } + + componentWillUnmount() { + if (this.editor) { + try { + const model = this.editor.getModel(); + if (model) { + model.dispose(); + } + + this.editor.dispose(); + } catch (err) { + console.error(`Error occurs in disposing editor: ${JSON.stringify(err)}`); + } + } + } + + render() { + return ( +
+
+
+ ); + } + + /** + * Register default kernel-based completion provider. + * @param language Language + */ + registerDefaultCompletionProvider(language: string) { + // onLanguage event is emitted only once per language when language is first time needed. + monaco.languages.onLanguage(language, () => { + // monaco.languages.registerCompletionItemProvider(language, completionProvider); + }); + } + + private onFocus() { + this.props.onFocusChange(true); + this.toggleEditorOptions(true); + this.registerCursorListener(); + } + + private onBlur() { + this.props.onFocusChange(false); + this.toggleEditorOptions(false); + this.unregisterCursorListener(); + } + + private 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) + ); + } + } + } + + private 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. + */ + private toggleEditorOptions(isActive: boolean) { + if (this.editor) { + this.editor.updateOptions({ + matchBrackets: isActive, + occurrencesHighlight: isActive, + renderIndentGuides: isActive + }); + } + } + + /** + * Register language features for target language. Call before setting language type to model. + */ + private registerCompletionProvider() { + const { enableCompletion, language, onRegisterCompletionProvider, shouldRegisterDefaultCompletion } = this.props; + + if (enableCompletion && language) { + if (onRegisterCompletionProvider) { + onRegisterCompletionProvider(language); + } else if (shouldRegisterDefaultCompletion) { + this.registerDefaultCompletionProvider(language); + } + } + } +} + +export default connect(makeMapStateToProps)(MonacoEditor); diff --git a/src/Explorer/Notebook/MonacoEditor/converter.ts b/src/Explorer/Notebook/MonacoEditor/converter.ts new file mode 100644 index 000000000..f64d81af6 --- /dev/null +++ b/src/Explorer/Notebook/MonacoEditor/converter.ts @@ -0,0 +1,43 @@ +import Immutable from "immutable"; +// import * as monaco from "./monaco"; +import * as monaco from "monaco-editor/esm/vs/editor/editor.api"; + +/** + * Code Mirror to Monaco constants. + */ +export enum Mode { + markdown = "markdown", + raw = "plaintext", + python = "python" +} + +/** + * Maps Code Mirror mode to a valid Monaco Editor supported langauge + * defaults to plaintext if map not found. + * @param mode Code Mirror mode + * @returns Monaco language + */ +export function mapCodeMirrorModeToMonaco(mode: any): string { + let language = ""; + + // Parse codemirror mode object + if (typeof mode === "string") { + language = mode; + } + // Vanilla object + else if (typeof mode === "object" && mode.name) { + language = mode.name; + } + // Immutable Map + else if (Immutable.Map.isMap(mode) && mode.has("name")) { + language = mode.get("name"); + } + + // Need to handle "ipython" as a special case since it is not a registered language + if (language === "ipython") { + return Mode.python; + } else if (monaco.languages.getEncodedLanguageId(language) > 0) { + return language; + } + return Mode.raw; +} diff --git a/src/Explorer/Notebook/MonacoEditor/selectors.ts b/src/Explorer/Notebook/MonacoEditor/selectors.ts new file mode 100644 index 000000000..de6518357 --- /dev/null +++ b/src/Explorer/Notebook/MonacoEditor/selectors.ts @@ -0,0 +1,68 @@ +import { AppState, ContentRef, selectors as nteractSelectors } from "@nteract/core"; +import { CellId } from "@nteract/commutable"; +import { Mode, mapCodeMirrorModeToMonaco } from "./converter"; + +/** + * Returns the language to use for syntax highlighting and autocompletion in the Monaco Editor for a given cell, falling back to the notebook language if one for the cell is not defined. + */ +export const getCellMonacoLanguage = ( + state: AppState, + contentRef: ContentRef, + cellId: CellId, + cellLanguageOverride?: string, + notebookLanguageOverride?: string +) => { + const model = nteractSelectors.model(state, { contentRef }); + if (!model || model.type !== "notebook") { + throw new Error("Connected Editor components should not be used with non-notebook models"); + } + + const cell = nteractSelectors.notebook.cellById(model, { id: cellId }); + if (!cell) { + throw new Error("Invalid cell id"); + } + + switch (cell.cell_type) { + case "markdown": + return Mode.markdown; + case "raw": + return Mode.raw; + case "code": + if (cellLanguageOverride) { + return mapCodeMirrorModeToMonaco(cellLanguageOverride); + } else { + // Fall back to notebook language if cell language isn't present. + return getNotebookMonacoLanguage(state, contentRef, notebookLanguageOverride); + } + } + return Mode.raw; +}; + +/** + * Returns the language to use for syntax highlighting and autocompletion in the Monaco Editor for a given notebook. + */ +export const getNotebookMonacoLanguage = ( + state: AppState, + contentRef: ContentRef, + notebookLanguageOverride?: string +) => { + const model = nteractSelectors.model(state, { contentRef }); + if (!model || model.type !== "notebook") { + throw new Error("Connected Editor components should not be used with non-notebook models"); + } + + if (notebookLanguageOverride) { + return mapCodeMirrorModeToMonaco(notebookLanguageOverride); + } + + const kernelRef = model.kernelRef; + let codeMirrorMode = null; + // Try to get the CodeMirror mode from the kernel. + if (kernelRef) { + codeMirrorMode = nteractSelectors.kernel(state, { kernelRef })?.info?.codemirrorMode; + } + // As a fallback, get the CodeMirror mode from the notebook itself. + codeMirrorMode = codeMirrorMode ?? nteractSelectors.notebook.codeMirrorMode(model); + + return mapCodeMirrorModeToMonaco(codeMirrorMode); +}; diff --git a/src/Explorer/Notebook/MonacoEditor/styles.css b/src/Explorer/Notebook/MonacoEditor/styles.css new file mode 100644 index 000000000..0c0db64b3 --- /dev/null +++ b/src/Explorer/Notebook/MonacoEditor/styles.css @@ -0,0 +1,26 @@ +.monaco-container { + padding: 0px 0px 7px 0px; +} + +/* +For the following components, we use the inherited width values from monaco-container. +On resizing the browser, the width of monaco-container will be calculated +and we just use the calculated width for the following components +So we don't need to use Monaco editor's layout() function which is expensive operation and causes performance issues on resizing. +*/ +/* +TODO: These styles below are added for resizing perf improvement. +Once the virtualization is implemented, we will revisit this later. + */ +.monaco-container .monaco-editor { + width: inherit !important; +} + +.monaco-container .monaco-editor .overflow-guard { + width: inherit !important; +} + +/* 26px is the left margin for .monaco-scrollable-element */ +.monaco-container .monaco-editor .monaco-scrollable-element.editor-scrollable.vs { + width: calc(100% - 26px) !important; +} diff --git a/src/Explorer/Notebook/MonacoEditor/theme.ts b/src/Explorer/Notebook/MonacoEditor/theme.ts new file mode 100644 index 000000000..912fe25ee --- /dev/null +++ b/src/Explorer/Notebook/MonacoEditor/theme.ts @@ -0,0 +1,76 @@ +// import * as monaco from "./monaco"; +import * as monaco from "monaco-editor/esm/vs/editor/editor.api"; + +// TODO: move defineTheme calls to an initialization function + +/** + * The default light theme with customized background + */ +export const LightThemeName = "vs-light"; + +/** + * Default monaco theme for light theme + */ +export const customMonacoLightTheme: monaco.editor.IStandaloneThemeData = { + base: "vs", // Derive from default light theme of Monaco + inherit: true, + rules: [], + colors: { + // We want Monaco background to use the same background for our themes. + // Without this, the Monaco light theme has a yellowish tone. + // Verified with UX that white meets all the accessbility requirements for light + // and high contrast light theme. + "editor.background": "#FFFFFF" + } +}; + +monaco.editor.defineTheme(LightThemeName, customMonacoLightTheme); + +/** + * The default dark theme with customized background + */ +export const DarkThemeName = "aznb-dark"; + +/** + * Default monaco theme for dark theme + */ +export const customMonacoDarkTheme: monaco.editor.IStandaloneThemeData = { + base: "vs-dark", // Derive from default dark theme of Monaco + inherit: true, + rules: [], + colors: { + "editor.background": "#1b1a19" + } +}; + +monaco.editor.defineTheme(DarkThemeName, customMonacoDarkTheme); + +/** + * The custom high contrast light theme with customized background + */ +export const HCLightThemeName = "hc-light"; + +/** + * Default monaco theme for light high contrast mode + */ +export const customMonacoHCLightTheme: monaco.editor.IStandaloneThemeData = { + base: "vs", // Derive from default light theme of Monaco; change all grey colors to black to comply with highcontrast rules + inherit: true, + rules: [ + { token: "annotation", foreground: "000000" }, + { token: "delimiter.html", foreground: "000000" }, + { token: "operator.scss", foreground: "000000" }, + { token: "operator.sql", foreground: "000000" }, + { token: "operator.swift", foreground: "000000" }, + { token: "predefined.sql", foreground: "000000" } + ], + colors: { + // We want Monaco background to use the same background for our themes. + // Without this, the Monaco light theme has a yellowish tone. + // Verified with UX that white meets all the accessbility requirements for light + // and high contrast light theme. + "editor.background": "#FFFFFF" + } +}; + +monaco.editor.defineTheme(HCLightThemeName, customMonacoHCLightTheme); diff --git a/src/Explorer/Notebook/NotebookRenderer/NotebookRenderer.tsx b/src/Explorer/Notebook/NotebookRenderer/NotebookRenderer.tsx index 8388e0098..6a0db7b97 100644 --- a/src/Explorer/Notebook/NotebookRenderer/NotebookRenderer.tsx +++ b/src/Explorer/Notebook/NotebookRenderer/NotebookRenderer.tsx @@ -30,6 +30,7 @@ import { CellType } from "@nteract/commutable/src"; import "./NotebookRenderer.less"; import HoverableCell from "./decorators/HoverableCell"; import CellLabeler from "./decorators/CellLabeler"; +import MonacoEditor from "../MonacoEditor/MonacoEditor"; export interface NotebookRendererProps { contentRef: any; @@ -98,7 +99,8 @@ class BaseNotebookRenderer extends React.Component { {{ editor: { codemirror: (props: PassedEditorProps) => ( - + // + ) }, prompt: ({ id, contentRef }: { id: CellId; contentRef: ContentRef }) => (