From 7bdbf2335463f73c5494bf5512679cab9fb8a0f1 Mon Sep 17 00:00:00 2001 From: Jan Potoms <2109932+Janpot@users.noreply.github.com> Date: Thu, 28 Jul 2022 18:13:44 +0200 Subject: [PATCH] Fix interferring monaco editor instances (#702) * Fix interferring monaco editor instances * terser * defaults * types --- .../toolpad-app/src/components/JsonEditor.tsx | 19 ++- .../src/components/MonacoEditor.tsx | 160 ++++++++++++++++-- .../src/components/TypescriptEditor.tsx | 29 ++-- .../toolpad-app/typings/monaco-editor.d.ts | 13 ++ 4 files changed, 181 insertions(+), 40 deletions(-) create mode 100644 packages/toolpad-app/typings/monaco-editor.d.ts diff --git a/packages/toolpad-app/src/components/JsonEditor.tsx b/packages/toolpad-app/src/components/JsonEditor.tsx index cdf8dcc9e3f..4021f20cd7a 100644 --- a/packages/toolpad-app/src/components/JsonEditor.tsx +++ b/packages/toolpad-app/src/components/JsonEditor.tsx @@ -3,17 +3,17 @@ */ import * as React from 'react'; -import MonacoEditor, { MonacoEditorHandle, MonacoEditorProps } from './MonacoEditor'; +import type * as monaco from 'monaco-editor'; +import MonacoEditor, { MonacoEditorProps } from './MonacoEditor'; -export interface JsonEditorProps extends Omit { +export interface JsonEditorProps + extends Omit { schemaUri?: string; } export default function JsonEditor({ schemaUri, ...props }: JsonEditorProps) { - const editorRef = React.useRef(null); - - React.useEffect(() => { - editorRef.current?.monaco.languages.json.jsonDefaults.setDiagnosticsOptions({ + const diagnostics = React.useMemo( + () => ({ validate: true, schemaRequest: 'error', enableSchemaRequest: true, @@ -25,8 +25,9 @@ export default function JsonEditor({ schemaUri, ...props }: JsonEditorProps) { }, ] : [], - }); - }, [schemaUri]); + }), + [schemaUri], + ); - return ; + return ; } diff --git a/packages/toolpad-app/src/components/MonacoEditor.tsx b/packages/toolpad-app/src/components/MonacoEditor.tsx index a6a05ea8f4d..3ef9137e15c 100644 --- a/packages/toolpad-app/src/components/MonacoEditor.tsx +++ b/packages/toolpad-app/src/components/MonacoEditor.tsx @@ -9,9 +9,22 @@ import { styled, SxProps } from '@mui/material'; import clsx from 'clsx'; import cuid from 'cuid'; import invariant from 'invariant'; +import { + conf as jsonBasicConf, + language as jsonBasicLanguage, +} from 'monaco-editor/esm/vs/basic-languages/javascript/javascript'; +import { + conf as typescriptBasicConf, + language as typescriptBasicLanguage, +} from 'monaco-editor/esm/vs/basic-languages/typescript/typescript'; import monacoEditorTheme from '../monacoEditorTheme'; import muiTheme from '../theme'; +export interface ExtraLib { + content: string; + filePath?: string; +} + function getExtension(language: string): string { switch (language) { case 'typescript': @@ -58,6 +71,29 @@ window.MonacoEnvironment = { }, } as monaco.Environment; +function registerLanguage( + langId: string, + language: monaco.languages.IMonarchLanguage, + conf: monaco.languages.LanguageConfiguration, +) { + monaco.languages.register({ id: langId }); + monaco.languages.registerTokensProviderFactory(langId, { + create: async (): Promise => language, + }); + monaco.languages.onLanguage(langId, async () => { + monaco.languages.setLanguageConfiguration(langId, conf); + }); +} + +/** + * Monaco language services are singletons, we can't set language options per editor instance. + * We're working around this limitiation by only considering diagnostics for the focused editor. + * Unfocused editors will be configured with a syntax-coloring-only language which are registered below. + * See https://github.com/microsoft/monaco-editor/issues/1105 + */ +registerLanguage('jsonBasic', jsonBasicLanguage, jsonBasicConf); +registerLanguage('typescriptBasic', typescriptBasicLanguage, typescriptBasicConf); + monaco.languages.typescript.typescriptDefaults.setCompilerOptions({ target: monaco.languages.typescript.ScriptTarget.Latest, allowNonTsExtensions: true, @@ -71,10 +107,35 @@ monaco.languages.typescript.typescriptDefaults.setCompilerOptions({ typeRoots: ['node_modules/@types'], }); -monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({ +const JSON_DEFAULT_DIAGNOSTICS_OPTIONS: monaco.languages.json.DiagnosticsOptions = {}; + +monaco.languages.json.jsonDefaults.setDiagnosticsOptions(JSON_DEFAULT_DIAGNOSTICS_OPTIONS); + +const TYPESCRIPT_DEFAULT_DIAGNOSTICS_OPTIONS: monaco.languages.typescript.DiagnosticsOptions = { noSemanticValidation: false, noSyntaxValidation: false, -}); +}; + +monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions( + TYPESCRIPT_DEFAULT_DIAGNOSTICS_OPTIONS, +); + +const TYPESCRIPT_DEFAULT_COMPILER_OPTIONS: monaco.languages.typescript.CompilerOptions = { + target: monaco.languages.typescript.ScriptTarget.Latest, + allowNonTsExtensions: true, + moduleResolution: monaco.languages.typescript.ModuleResolutionKind.NodeJs, + module: monaco.languages.typescript.ModuleKind.CommonJS, + noEmit: true, + esModuleInterop: true, + jsx: monaco.languages.typescript.JsxEmit.React, + reactNamespace: 'React', + allowJs: true, + typeRoots: ['node_modules/@types'], +}; + +monaco.languages.typescript.typescriptDefaults.setCompilerOptions( + TYPESCRIPT_DEFAULT_COMPILER_OPTIONS, +); const classes = { monacoHost: 'Toolpad_MonacoEditorMonacoHost', @@ -128,25 +189,49 @@ export interface MonacoEditorHandle { type EditorOptions = monaco.editor.IEditorOptions & monaco.editor.IGlobalEditorOptions; -export interface MonacoEditorProps { +interface MonacoEditorBaseProps { value?: string; onChange?: (newValue: string) => void; disabled?: boolean; sx?: SxProps; autoFocus?: boolean; - language?: string; onFocus?: () => void; onBlur?: () => void; options?: EditorOptions; className?: string; } +export type MonacoEditorProps = MonacoEditorBaseProps & + ( + | { + language?: 'typescript' | undefined; + diagnostics?: monaco.languages.typescript.DiagnosticsOptions; + compilerOptions?: monaco.languages.typescript.CompilerOptions | undefined; + extraLibs?: ExtraLib[]; + } + | { + language: 'json'; + diagnostics?: monaco.languages.json.DiagnosticsOptions; + compilerOptions?: undefined; + extraLibs?: undefined; + } + | { + language: 'css'; + diagnostics?: undefined; + compilerOptions?: undefined; + extraLibs?: undefined; + } + ); + export default React.forwardRef(function MonacoEditor( { value, onChange, sx, language = 'typescript', + diagnostics, + compilerOptions, + extraLibs, onFocus, onBlur, className, @@ -159,6 +244,50 @@ export default React.forwardRef(function const rootRef = React.useRef(null); const instanceRef = React.useRef(null); + const [isFocused, setIsFocused] = React.useState(false); + + React.useEffect(() => { + /** + * Update the language and diagnostics of the currently focused editor. Non-focused editors + * will get a syntax-coloring-only version of the language. + * This is our workaround for having different diagnostics options per editor instance. + * See https://github.com/microsoft/monaco-editor/issues/1105 + */ + const model = instanceRef.current?.getModel(); + if (!model) { + return; + } + + if (language === 'json') { + if (isFocused) { + monaco.editor.setModelLanguage(model, 'json'); + monaco.languages.json.jsonDefaults.setDiagnosticsOptions({ + ...JSON_DEFAULT_DIAGNOSTICS_OPTIONS, + ...(diagnostics as monaco.languages.json.DiagnosticsOptions), + }); + } else { + monaco.editor.setModelLanguage(model, 'jsonBasic'); + } + } else if (language === 'typescript') { + if (isFocused) { + monaco.editor.setModelLanguage(model, 'typescript'); + monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({ + ...TYPESCRIPT_DEFAULT_DIAGNOSTICS_OPTIONS, + ...(diagnostics as monaco.languages.typescript.DiagnosticsOptions), + }); + monaco.languages.typescript.typescriptDefaults.setCompilerOptions({ + ...TYPESCRIPT_DEFAULT_COMPILER_OPTIONS, + ...compilerOptions, + }); + monaco.languages.typescript.typescriptDefaults.setExtraLibs(extraLibs || []); + } else { + monaco.editor.setModelLanguage(model, 'typescriptBasic'); + } + } else { + monaco.editor.setModelLanguage(model, language); + } + }, [isFocused, language, diagnostics, extraLibs, compilerOptions]); + React.useEffect(() => { invariant(rootRef.current, 'Ref not attached'); @@ -167,20 +296,22 @@ export default React.forwardRef(function ...options, }; - if (instanceRef.current) { + let instance = instanceRef.current; + + if (instance) { if (extraOptions) { - instanceRef.current.updateOptions(extraOptions); + instance.updateOptions(extraOptions); } - const model = instanceRef.current.getModel(); + const model = instance.getModel(); if (typeof value === 'string' && model) { const actualValue = model.getValue(); if (value !== actualValue) { // Used to restore cursor position - const state = instanceRef.current.saveViewState(); + const state = instance.saveViewState(); - instanceRef.current.executeEdits(null, [ + instance.executeEdits(null, [ { range: model.getFullModelRange(), text: value, @@ -188,7 +319,7 @@ export default React.forwardRef(function ]); if (state) { - instanceRef.current.restoreViewState(state); + instance.restoreViewState(state); } } } @@ -196,7 +327,7 @@ export default React.forwardRef(function const pathUri = monaco.Uri.parse(`./scripts/${cuid()}${getExtension(language)}`); const model = monaco.editor.createModel(value || '', language, pathUri); - instanceRef.current = monaco.editor.create(rootRef.current, { + instance = monaco.editor.create(rootRef.current, { model, language, minimap: { enabled: false }, @@ -210,8 +341,13 @@ export default React.forwardRef(function ...extraOptions, }); + instanceRef.current = instance; + + instance.onDidFocusEditorWidget(() => setIsFocused(true)); + instance.onDidBlurEditorWidget(() => setIsFocused(false)); + if (autoFocus && !disabled) { - instanceRef.current.focus(); + instance.focus(); } } }, [language, value, options, disabled, autoFocus]); diff --git a/packages/toolpad-app/src/components/TypescriptEditor.tsx b/packages/toolpad-app/src/components/TypescriptEditor.tsx index 5f13f4e55ca..cd376f78722 100644 --- a/packages/toolpad-app/src/components/TypescriptEditor.tsx +++ b/packages/toolpad-app/src/components/TypescriptEditor.tsx @@ -3,31 +3,22 @@ */ import * as React from 'react'; -import MonacoEditor, { MonacoEditorHandle, MonacoEditorProps } from './MonacoEditor'; +import type * as monaco from 'monaco-editor'; +import MonacoEditor, { MonacoEditorProps } from './MonacoEditor'; -export interface TypescriptEditorProps extends Omit { +export interface TypescriptEditorProps extends Omit { value: string; onChange: (newValue: string) => void; - extraLibs?: { content: string; filePath?: string }[]; functionBody?: boolean; } -export default function TypescriptEditor({ - extraLibs, - functionBody, - ...props -}: TypescriptEditorProps) { - const editorRef = React.useRef(null); - - React.useEffect(() => { - editorRef.current?.monaco.languages.typescript.typescriptDefaults.setExtraLibs(extraLibs || []); - }, [extraLibs]); - - React.useEffect(() => { - editorRef.current?.monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({ +export default function TypescriptEditor({ functionBody, ...props }: TypescriptEditorProps) { + const diagnostics = React.useMemo( + () => ({ diagnosticCodesToIgnore: functionBody ? [1108] : [], - }); - }, [functionBody]); + }), + [functionBody], + ); - return ; + return ; } diff --git a/packages/toolpad-app/typings/monaco-editor.d.ts b/packages/toolpad-app/typings/monaco-editor.d.ts new file mode 100644 index 00000000000..45f72432475 --- /dev/null +++ b/packages/toolpad-app/typings/monaco-editor.d.ts @@ -0,0 +1,13 @@ +declare module 'monaco-editor/esm/vs/basic-languages/javascript/javascript' { + import type * as monaco from 'monaco-editor'; + + export const conf: monaco.languages.LanguageConfiguration; + export const language: monaco.languages.IMonarchLanguage; +} + +declare module 'monaco-editor/esm/vs/basic-languages/typescript/typescript' { + import type * as monaco from 'monaco-editor'; + + export const conf: monaco.languages.LanguageConfiguration; + export const language: monaco.languages.IMonarchLanguage; +}