Günther Debrauwer - August 29th, 2021

Monaco Editor in Laravel Livewire

In a recent project at work, a user needed to type html and handlebars code in a textarea. To make this user-friendly, I wanted to add code highlighting. While searching on how to do this I stumbled upon the Monaco Editor. This is the code editor that powers VS Code, but you can also use it in a regular web application. Because the documentation is not very good, I had a bit of trouble getting it working. So I decided to write this blog post.

Npm packages

The first step is installing 2 npm packages: monaco-editor and monaco-editor-webpack-plugin. The first package is the editor itself, while the second package is a webpack plugin that we will add to the configuration of Laravel Mix.

npm install monaco-editor monaco-editor-webpack-plugin —save-dev

Webpack configuration

In the webpack.mix.js file we need to install the webpack plugin. This can be done using the mix.webpackConfig method. When creating the MonacoWebpackPlugin object, we can define some options.

First, we have to define which languages we need. If you don’t set this option, then the plugin will assume you will use all languages. This will cause the creation of many unnecessary javascript files when building, which is probably not what you want.

All supported languages are documented here. In the list of supported languages, you will see that certain languages share the same web worker. For example, the handlebars language uses the same worker as the html language. This means, when you want to use handlebars, you also need to include html in the languages array.

Secondly, you can configure which features need to be enabled. By only enabling the features you need, you will reduce the size of the Monaco bundle. All features are documented here.

Lastly, we need to set the globalAPI option to true. This makes Monaco globally accessible on the window object.

const MonacoWebpackPlugin = require('monaco-editor-webpack-plugin');

mix.webpackConfig({
    plugins: [
        new MonacoWebpackPlugin({
            languages: ['handlebars', 'html'],
            features: ['accessibilityHelp', 'anchorSelect', 'bracketMatching', 'caretOperations', 'folding', 'format'],
            globalAPI: true,
        })
    ]
});

MonacoEnvironment object in monaco.js file

Next, we need to create a monaco.js file within the resources/js directory. You can import the editor in your main app.js file, but I prefer to create a separate file. You probably don’t need the Monaco Editor on every page of your application, so by creating a separate file you can just import the monaco.js file on the few web pages that use it. When you create a monaco.js file, you have to update your webpack.mix.js file so Laravel Mix will build it.

mix.js('resources/js/monaco.js', 'public/js')

In the monaco.js file, first, we need to import the Monaco Editor itself. Secondly, we need to define a global MonacoEnvironment object. This object needs to contain a getWorker and getWorkerUrl method. You can use the editor without defining these methods, but then you will not be able to get the updated content when a user adds or removes code in the editor. The Monaco Editor uses a language-specific web worker to compute heavy stuff outside the UI thread and that includes syncing the updated content. In the MonacoEnvironment.getWorkerUrl method we need to return the specific web worker url of each language. As previously mentioned, certain languages use the same web worker, so this should be taken into account in the getWorkerUrl method.

import 'monaco-editor/esm/vs/editor/editor.api';

window.MonacoEnvironment = {
    getWorkerUrl: (moduleId, label) => {
        if (label === 'html' || label === 'handlebars') {
            return '/html.worker.js';
        }

        return '/editor.worker.js';
    },
    getWorker: (moduleId, label) => {
        return new Worker(window.MonacoEnvironment.getWorkerUrl(moduleId, label));
    },
};

Creating a Monaco Editor instance

After all this setup, we can finally create a Monaco Editor instance. This can be done using Alpine.js. There are two important notes about this setup. First is the automaticLayout option. By enabling this the editor will automatically resize when needed. Otherwise, it will just keep its initial rendered size. Secondly, you need to add the wire:ignore attribute to the html element that initializes the editor. The Monaco Editor will create a lot of html that should be ignored by Livewire.

<div x-data="{ content: '' }">
    <div
        x-init="
            document.addEventListener('DOMContentLoaded', () => {
                let editor = monaco.editor.create($el, {
                    value: content,
                    language: 'handlebars',
                    automaticLayout: true,
                });
            });
        "
        wire:ignore
        class="h-full w-full"
    ></div>
</div>

Now we have an editor, but we don’t receive any content updates yet. To receive those updates, we will need the onDidChangeContent method.

editor.getModel().onDidChangeContent(() => content = editor.getValue());

If you combine this with Livewire’s @entangle method, then you have an editor that will automatically pass its changes to Livewire.

<div x-data="{ content: @entangle(‘content’).defer }">
    <div
        x-init="
            document.addEventListener('DOMContentLoaded', () => {
                let editor = monaco.editor.create($el, {
                    value: content,
                    language: 'handlebars',
                    automaticLayout: true,
                });

                editor.getModel().onDidChangeContent(() => content = editor.getValue());
            });
        "
        wire:ignore
        class="h-full w-full"
    ></div>
</div>

Customizing the editor

You might want to customize certain parts of the Monaco Editor. You can create a custom theme if you want to customize the colors. You can find the allowed theme options in the API documentation of the Monaco Editor. In my project, I extended the default light theme and changed its background from white to light gray.

// in resources/js/monaco.js

import tailwindColors from 'tailwindcss/colors';

monaco.editor.defineTheme('gray', {
    base: 'vs',
    inherit: true,
    rules: [
        {
            background: tailwindColors.gray['50'],
        },
    ],
    colors: {
        'editor.background': tailwindColors.gray['50'],
    },
});

When creating the Monaco Editor instance itself, you can provide some extra options as well. For example, in my project, I disabled the line numbers, added some padding at the top, and hid the minimap. You can find all the options in the API documentation of the Monaco Editor.

let editor = monaco.editor.create($el, {
    value: content,
    language: 'handlebars',
    automaticLayout: true,
    lineNumbers: 'off',
    minimap: {
        enabled: false
    },
    theme: 'gray',
    padding: {
        top: 16
    }
});