The author: Taro son

The team is currently working on a low-code platform that will support LowCode/ProCode dual-mode online development, while the ProCode scenario requires a relatively full-featured WebIDE running in a browser. At the same time, considering the future requirements of some block code platforms, we separate the WebIDE module to deal with more personalized requirements in the later stage.

Thanks to the power of the Monaco-Editor, it is easy to build a simple WebIDE using the Monaco-Editor, but it is not easy to add multi-file support, ESLint, Prettier, code completion, and so on.

The purpose of this article is to share some of the experiences and solutions learned in the construction of WebIDE, hoping to help students with the same needs. At the same time, this is not a hand-to-hand article, but just an introduction to some decision-making ideas and sample code. The complete code can be found in Github, and a demo has been built for you to experience (Demo relies on a lot of static files and is deployed on Github Pages. If the access speed is too slow, it may not be able to load normally. An NPM component is also provided that can be used directly as the React component.

Compared with the mature @monaco-Editor/React in the industry, the WebIDE provided in this paper directly aggregates file directory tree, file navigation and save state into components, and also provides the support of Eslint, Prettier and other basic capabilities, which can greatly reduce the cost of secondary development.

About CloudIDE and WebIDE

Before we get started, let’s talk about CloudIDE and WebIDE.

Previously, I built a CloudIDE platform based on Theia (see this article for related introduction). Essentially, the front end of IDE runs in the browser or local ELECTRON, while the file system and multi-language service run in the remote container side. In the middle through RPC communication to complete the whole IDE cloud.

The IDE shared in this article does not adopt a containerized solution. Instead, based on the Monaco-Editor, some of the services that originally run in the remote container, such as multilanguage services and Eslint checks, are run in the browser through the Web worker. In contrast to containerized solutions, lightweight ides do not have command line terminal capabilities.

Ides that rely on containerization to provide full terminal capabilities are called CloudIDE in this article, while ides that rely solely on browser capabilities are called Webides in this article. The IDE I want to share in this article falls into the latter category.

The introduction of Monaco – editor

There are two main ways to introduce monaco-Editor: AMD or ESM.

Both access methods are relatively easy, I have tried both.

Relatively speaking, I prefer esM at first, but due to the issue, it can be used normally in the current project after packaging, but when it is released as NPM package and used by others, the packaging will be wrong.

Therefore, the first method is adopted to introduce monaco-Editor by dynamically inserting script tags. In the project, the timer polls the existence of window. Monaco to determine whether the monaco-Editor has been loaded. Provide a loading to wait.

Multifile support

Monaco-editor’s official examples are mostly single-file processing, but multi-file processing is also very simple, so this article only gives a brief introduction here.

Multiple file processing mainly involves is Monaco. The editor. Create and Monaco. Editor. CreateModel two apis.

CreateModel is the core API for multi-file processing. Create different models based on the file path, and when you need to switch, you can switch multiple files by calling editor.setModel

Common pseudocode for creating multiple files and switching is as follows:

const files = {
    '/test.js': 'xxx'.'/app/test.js': 'xxx2',}consteditor = monaco.editor.create(domNode, { ... options,model: null.// Set model to null to prevent the default creation of an empty model
});

Object.keys(files).forEach((path) = >
    monaco.editor.createModel(
        files[path],
        'javascript'.new monaco.Uri().with({ path })
    )
);

function openFile(path) {
    const model = monaco.editor.getModels().find(model= > model.uri.path === path);
    editor.setModel(model);
}

openFile('/test.js');
Copy the code

Through writing a certain UI code, it is very easy to achieve the switch of multiple files.

Retain the status before the switchover

Through the above method, multiple files can be switched, but before and after the file switch, the scrolling position of the mouse and the selected state of the text will be lost.

At this point, you can create a map to store the states of different files before switching. The core code is as follows:

const editorStatus = new Map(a);const preFilePath = ' ';

consteditor = monaco.editor.create(domNode, { ... options,model: null});function openFile(path) {
    const model = monaco.editor
        .getModels()
        .find(model= > model.uri.path === path);
        
    if(path ! == preFilePath) {// Store the state of the editor of the previous path
        editorStatus.set(preFilePath, editor.saveViewState());
    }
    // Switch to the new model
    editor.setModel(model);
    const editorState = editorStates.get(path);
    if (editorState) {
        // Restore the editor state
        editor.restoreViewState(editorState);
    }
    // Focus editor
    editor.focus();
    preFilePath = path;
}
Copy the code

The core is to use the saveViewState method of the Editor instance to store the editor state and restore it through the restoreViewState method.

File a jump

Monaco-editor, as an excellent editor, is itself a reminder that can sense the existence of other models and complete relevant code. Although hover can see the relevant information, but we most commonly used CMD + click, the default is not able to jump.

This is also a relatively common problem, detailed reasons and solutions can be found in this issue.

Simply put, the library itself does not implement this opening because there is no obvious way for the user to jump back in if a jump is allowed.

In practice, this can be done by overwriting The openCodeEditor and implementing the Model switch without finding the jump result

    const editorService = editor._codeEditorService;
    const openEditorBase = editorService.openCodeEditor.bind(editorService);
    editorService.openCodeEditor = async (input, source) =>  {
        const result = await openEditorBase(input, source);
        if (result === null) {
            const fullPath = input.resource.path;
            // Jump to the corresponding model
            source.setModel(monaco.editor.getModel(input.resource));
            // You can also add your own file selection and other processing
        
            // Set the selected area and the number of rows to focus
            source.setSelection(input.options.selection);
            source.revealLine(input.options.selection.startLineNumber);
        }
        return result; // always return the base result
    };
Copy the code

controlled

In the actual writing of the React component, controlled manipulation of the file content is often required, which requires the editor to notify the outside world of changes in the content and allow the outside world to directly modify the text content.

Starting with listening for content changes, each model of the Monaco-Editor provides methods like onDidChangeContent to listen for file changes, so we can continue to modify our openFile function.


let listener = null;

function openFile(path) {
    const model = monaco.editor
        .getModels()
        .find(model= > model.uri.path === path);
        
    if(path ! == preFilePath) {// Store the state of the editor of the previous path
        editorStatus.set(preFilePath, editor.saveViewState());
    }
    // Switch to the new model
    editor.setModel(model);
    const editorState = editorStates.get(path);
    if (editorState) {
        // Restore the editor state
        editor.restoreViewState(editorState);
    }
    // Focus editor
    editor.focus();
    preFilePath = path;
    
    if (listener) {
        // Cancel the last listener
        listener.dispose();
    }
    
    // Listen for file changes
    listener = model.onDidChangeContent(() = > {
        const v = model.getValue();
        if (props.onChange) {
            props.onChange({
                value: v,
                path,
            })
        }
    })
}
Copy the code

The outside world can directly modify the value of the file by using model.setValue, but this direct operation will lose the stack of editor undo. If you want to retain undo, The substitution can be done with model.pusheditOperations as follows:

function updateModel(path, value) {
    let model = monaco.editor.getModels().find(model= > model.uri.path === path);
    
    if(model && model.getValue() ! == value) {// Using this method, you can preserve the undo stack
        model.pushEditOperations(
            [],
            [
                {
                    range: model.getFullModelRange(),
                    text: value
                }
            ],
            () = >{},}}Copy the code

summary

With the API provided by the Monaco-Editor above, you can almost complete the support for multiple files.

Of course, there is a lot of work to implement, file tree list, top TAB, unsaved state, file navigation and so on. However, this part belongs to most of our front-end daily work, although the workload is not small but the implementation is not complicated, I will not repeat here.

ESLint support

The Monaco-Editor itself has a syntax analysis, but it contains only checks for syntax errors, not code style checks. Of course, there should be no code style checks.

As a modern front-end developer, almost every project has an ESLint configuration, and while WebIDE is a lite version, ESLint is still essential.

Plan to explore

The principle of ESLint is to traverse the syntax tree and verify that its core Linter is independent of the Node environment and is packaged separately by clone the official code. Run NPM Run webpack to get the core packaged eslint.js. This is essentially a package of linter.js files.

There is also an official demo of ESLint based on the package.

The linter can be used as follows:

import { Linter } from 'path/to/bundled/ESLint.js';

const linter = new Linter();

// Define new rules, like react/hooks, react special rules
// Linter already defines all the basic rules that include ESLint, here are more plugin rules definitions.Linter. DefineRule (ruleName, ruleImpl); linter.verify(text, {rules: {
        'some rules you want': 'off or warn',},settings: {},
    parserOptions: {},
    env: {},})Copy the code

There are several problems with using only the methods provided by Linter above:

  1. There are too many rules, too many rules to write one by one and not necessarily conform to team specifications
  2. Some plugin rules cannot be used, such as eslint-plugin-react, react-hooks rules that the React project strongly relies on.

Therefore, some specific customization is also needed.

Custom browser version of ESLint

In the react project, teams configure most rules based on eslint-config-Airbnb rules, and then adapt some rules according to the team.

By reading the eslint-config-Airbnb code, it does two things:

  1. Most of the rules that come with ESLint are configured
  2. Rules for ESLint plugins, eslint-plugin-react, eslint-plugin-react-hooks, are also configured.

Eslint-plugin-react-hooks eslint-plugin-react-hooks rules eslint-plugin-react-hooks rules eslint-plugin-react-hooks rules eslint-plugin-react-hooks

So the solution is as follows:

  1. The Linter class exported using the packaged eslint.js
  2. Add react, react/hooks rules with their defineRule methods
  3. Combine airbnb’s rules as a config set of various rules
  4. To complete ESLint verification, call the linter.verify method in conjunction with the Airbnb rules generated in 3.

Using the above method, you can generate a Linter suitable for daily use and a ruleConfig suitable for use in react projects. Because this part is relatively independent, I put it in a github repository yuzai/ ESlint-Browser separately, which can be used as a reference or modified according to the current situation of the team.

Determine call timing

With esLint’s customization resolved, the next step is the timing of the call. Executing ESLint’s Verify frequently on each code change can cause a UI freeze. Here’s my solution:

  1. Perform linter.verify via webworker
  2. Notify the worker to execute in model.ondidChangecontent. And reduce the frequency of execution through shaking prevention
  3. Get the current ID with model.getVersionId to avoid the problem of too much delay causing the results to be mismatched

The code for the main process core is as follows:

// Listen for returns from the ESLint Web worker
worker.onmessage = function (event) {
    const { markers, version } = event.data;
    const model = editor.getModel();
    // determine whether the current versionId of the model is the same as when requested
    if (model && model.getVersionId() === version) {
        window.monaco.editor.setModelMarkers(model, 'ESLint', markers); }};let timer = null;
// Notify ESLint worker when model content changes
model.onDidChangeContent(() = > {
    if (timer) clearTimeout(timer);
    timer = setTimeout(() = > {
        timer = null;
        worker.postMessage({
            code: model.getValue(),
            // The notification is initiated with the versionId
            version: model.getVersionId(),
            path,
        });
    }, 500);
});
Copy the code

The core code in worker is as follows:

// Introduce ESLint with the following internal structure:
/* {esLinter, // has been instantiated, and added instances of the react, react/hooks rule definition // incorporate the airbnb-config rule config: {rules, parserOptions: { ecmaVersion: 'latest', sourceType: 'module', ecmaFeatures: { jsx: true } }, env: { browser: true }, } } */
importScripts('path/to/bundled/ESLint/and/ESLint-airbnbconfig.js');

/ / more detailed config, reference ESLint linter source of definition about the config: https://github.com/ESLint/ESLint/blob/main/lib/linter/linter.js#L1441
constconfig = { ... self.linter.config,rules: {
        ...self.linter.config.rules,
        // You can override the original rules by custom
    },
    settings: {},}/ / Monaco definition can refer to: https://microsoft.github.io/monaco-editor/api/enums/monaco.MarkerSeverity.html
const severityMap = {
    2: 8.// 2 for ESLint is error
    1: 4.// 1 for ESLint is warning
}

self.addEventListener('message'.function (e) {
    const { code, version, path } = e.data;
    const extName = getExtName(path);
    // For non-JS, JSX code, no validation is done
    if (['js'.'jsx'].indexOf(extName) === -1) {
        self.postMessage({ markers: [], version });
        return;
    }
    const errs = self.linter.esLinter.verify(code, config);
    const markers = errs.map(err= > ({
        code: {
            value: err.ruleId,
            target: ruleDefines.get(err.ruleId).meta.docs.url,
        },
        startLineNumber: err.line,
        endLineNumber: err.endLine,
        startColumn: err.column,
        endColumn: err.endColumn,
        message: err.message,
        // Set the error level, where ESLint is different from Monaco, and do a layer mapping
        severity: severityMap[err.severity],
        source: 'ESLint',}));// Send back to the main process
    self.postMessage({ markers, version });
});
Copy the code

The host process monitors the text changes, and then passes it to the worker for linter, carrying the versionId as the returned comparison marker. After verification, linter returns the markers to the host process, which sets the markers.

That’s the entire ESLint process.

Of course, due to the time, only JS, JSX, not TS, TSX file processing. Support ts need to call Linter’s defineParser to modify the syntax tree parser, relatively a little troublesome, so far no attempt has been made, subsequent changes will be in github repository Yuzai/eslint-Browser modification synchronization.

Prettier support

Prettier supports amd, CommonJS, and es modules while ESLint does Prettier

The core of its usage method is to call different parsers to parse different files. In my current scenario, the following parsers are used:

  1. Babel: dealing with js
  2. HTML: HTML
  3. Postcss: Used to process CSS, LESS, and SCSS
  4. Ts typescript: processing

The differences can be referred to the official documents, not described here. A very simple usage code is as follows:

const text = Prettier.format(model.getValue(), {
    // Specify the file path
    filepath: model.uri.path,
    / / parser collection
    plugins: PrettierPlugins,
    / / more options: https://Prettier.io/docs/en/options.html
    singleQuote: true.tabWidth: 4});Copy the code

In the above configuration, there is one configuration to note: filepath.

This configuration is used to tell Prettier what file it is and what parser to call for processing. In the current WebIDE scenario, you can simply pass the file path, or you can specify which parser to use using the parser field after calculating the file suffix yourself.

When combined with monaco-Editor, the code is formatted when you need to listen for CMD + S shortcuts to save.

Considering that monaco-Editor itself provides formatting commands, which can be ⇧ + ⌥ + F.

Therefore, compared with CMD + S, it is better to directly overwrite the built-in formatting command to execute the customized function. In CMD + S, it is more elegant to directly execute the command to complete formatting.

Covered mainly by languages. RegisterDocumentFormattingEditProvider method, specific usage is as follows:

function provideDocumentFormattingEdits(model: any) {
    const p = window.require('Prettier');
    const text = p.Prettier.format(model.getValue(), {
        filepath: model.uri.path,
        plugins: p.PrettierPlugins,
        singleQuote: true.tabWidth: 4});return[{range: model.getFullModelRange(),
            text,
        },
    ];
}

monaco.languages.registerDocumentFormattingEditProvider('javascript', {
    provideDocumentFormattingEdits
});
monaco.languages.registerDocumentFormattingEditProvider('css', {
    provideDocumentFormattingEdits
});
monaco.languages.registerDocumentFormattingEditProvider('less', {
    provideDocumentFormattingEdits
});
Copy the code

In the code above, window.require is the mode of AMD. Because THE monaco-Editor in this paper adopted THE mode of AMD, Prettier also adopted the mode of AMD, and introduced from CDN to reduce the volume of packages. The specific code is as follows:

window.define('Prettier'['https://unpkg.com/[email protected]/standalone.js'.'https://unpkg.com/[email protected]/parser-babel.js'.'https://unpkg.com/[email protected]/parser-html.js'.'https://unpkg.com/[email protected]/parser-postcss.js'.'https://unpkg.com/[email protected]/parser-typescript.js'].(Prettier: any, ... args: any[]) = > {
    const PrettierPlugins = {
        babel: args[0].html: args[1].postcss: args[2].typescript: args[3],}return {
        Prettier,
        PrettierPlugins,
    }
});
Copy the code

When adding Prettier, and providing a formatting provider, registering a ⇧ + ⌥ + F to format, the last step being to execute the command when the user CMD + s, using the editor.getAction method, the following code:

// editor The editor instance created for the create method
editor.getAction('editor.action.formatDocument').run()
Copy the code

The process of Prettier is completed as follows:

  1. Amd introduction
  2. Monaco. Languages. RegisterDocumentFormattingEditProvider modify Monaco default formatting code method
  3. Editor. GetAction (‘ editor. Action. FormatDocument). The run () format

Code completion

The Monaco-Editor itself already has common code completions, such as window variables, DOM, CSS properties, and so on. However, it does not provide code completion in node_modules, such as the most common react, without prompting, the experience will be much worse.

After investigation, the Monaco-Editor has at least two apis to provide an entry point for code hints:

  1. RegisterCompletionItemProvider, need to customize the triggering rules and content
  2. AddExtraLib, by adding index.d.ts, provides the variables resolved by index.d.ts for automatic completion during automatic input.

For the first solution, there are many articles on the Internet, but for practical requirements, import react and react-dom. If this solution is adopted, you need to complete the analysis of index.d.ts by yourself, and output the type definition scheme, which is very cumbersome in practical use and not conducive to later maintenance.

The second, more subtle, serendipitous solution proved to be the one stackBliz uses. Stackbliz only supports TS jump and code completion.

After testing, we only need to use addExtraLib in javascriptDefaults and typescriptDefaults in TS to complete the code.

The experience and cost are far better than plan 1.

The problem with option 2 is unknown third-party package resolution, and stackBliz is only doing.d.ts resolution for direct NPM dependencies. There is no subsequent dependency. It is actually understandable that quadratic introduced dependencies are not resolved without a quadratic resolution.d.ts. Therefore, the current version does not do index.d.ts resolution, only provides directly dependent code completion and jump. However, TS itself provides the ability to analyze types, and later access will be synchronized on Github.

Therefore, scheme 2 is finally used, with built-in react and react-DOM type definition, and the secondary dependent package parsing is not performed for the time being. Relevant pseudocodes are as follows:

window.monaco.languages.typescript.javascriptDefaults.addExtraLib(
    'content of react/index.d.ts'.'music:/node_modules/@types/react/index.d.ts'
);
Copy the code

In addition, the definition of.d.ts added by addExtraLib will also automatically create a model. With the help of the overwriting scheme described in the previous section of openCodeEditor, you can also implement the need for CMD + click to open index.d.ts. Better experience.

Subject to replace

Because the monaco-editor uses a different parser than vscode, it is not possible to use vscode themes in the Monaco editor. Of course, there are ways to use vscode theme articles in the Monaco editor. You can directly use vscode theme, I take is also this article scheme, itself is very detailed, I will not do the same work here.

Preview the sandbox

In this part, the company has a sandbox scheme based on CodesandBox, so when it is actually implemented inside the company, the WebIDE described in this paper is only a code editing and display scheme, and the sandbox rendering scheme based on CodesandBox is used in the actual preview.

In addition, thanks to the browser’s natural support for Modules, I also tried to preview JSX and LESS files through service worker without packaging, directly relying on the browser’s support for modules. This scheme can be directly used in simple scenarios. However, in actual scenarios, node_modules files need special processing, so no further attempt is made.

This part I have not done more in-depth attempts, so I do not repeat.

The last

This article details the steps necessary to build a lightweight WebIDE based on Monaco-Editor.

In general, the Monaco-Editor itself is quite capable, and with the help of its basic API and appropriate UI code, it is possible to build a usable WebIDE very quickly. But to do it well, it’s not that easy.

In this article, we introduce the API, detail processing of multiple files, browserization scheme of ESLint, fitting of Prettier and Monaco, and support for code completion. I hope I can help students who have the same needs.

Finally, the source code is presented, if you feel good, please give a thumbs-up or a small star.

Refer to the article

  1. Building a code editor with Monaco

  2. A step-by-step guide to implementing the VSCode theme in the Monaco Editor

This article is published by NetEase Cloud Music Technology team. Any unauthorized reprinting of this article is prohibited. We recruit technical positions all year round. If you are ready to change your job and you like cloud music, join us!