I’ve used the Monaco Editor for a number of personal projects. It certainly beats using a <input type="text" /> or <textarea> if you want to edit code or similar. But, and for me it’s a big but, it doesn’t come with a VIM mode out of the box. This is how I fix that for my projects.

There are existing packages to add vim support to Monaco, notably monaco-vim, but they don’t work for me, for reasons. So I ported monaco-vim to TypeScript, and fixed the reasons. You can find the results on github and npmjs, but this is an article to show a simple way to use Monaco with a VIM mode.

You can try it for yourself at pollrobots.com/monaco-and-vim and see the source code that this article walks through at github.com/pollrobots/monaco-and-vim

Project structure

I usually use React for front-end projects, but this is simple enough (because all the heavy lifting has been done by somebody else) that I’m just going to use typescript.

The only dependencies are on typescript, monaco-editor, and vim-monaco. These are all dev dependencies because I’m going to pull packages from Unpkg

Let’s setup:

  1. git

     $ cd ~/src
     $ mkdir monaco-and-vim
     $ cd monaco-and-vim
     $ git init
     Initialized empty Git repository in /home/pollrobots/src/monaco-and-vim/.git/
     $ echo 'node_modules
     > dist
     > ' > .gitignore
    
  2. npm

     $ npm init -y
     $ npm i -D typescript monaco-editor vim-monaco
     added 3 packages, and audited 4 packages in 13s
    
     found 0 vulnerabilities
    
  3. vscode

     $ echo '{
     >   "folders": [
     >     { "path": "." }
     >   ]
     > }' > monaco-and-vim.code-workspace
     $ code monaco-and-vim.code-workspace
    
  4. Commit the inital empty project

     $ git add .
     $ git commit -m 'Empty project'
    

HTML

We need a fairly minimal html file, I’ll add some more stuff to this in a bit to load scripts, styles, and the external packages.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8" />
    <title>Monaco/VIM</title>
</head>
<body>
    <div class="container">
        <div id="monaco"></div>
        <div id="status"></div>
    </div>
</body>
</html>

to help with development I’m going to use serve to run a local webserver

$ npm i -D serve

And add a few scripts to the package.json file to make this easy

"scripts": {
  "clean": "rm -rf dist && mkdir dist",
  "serve": "serve dist",
  "build": "npm run clean && npm run build:html",
  "build:html": "cp src/index.html dist"
}

You could achieve this just as easily in a makefile, but I’m using npm anyway…

$ npm run build
$ npm run serve

I can now visit http://localhost:3000 and see a blank page!

TypeScript

We need to tell the typescript compiler a little about what we are doing, so we need a tsconfig.json

This is pretty minimalistic, but it should be sufficient. We may revisit.

{
  "compilerOptions": {
    "outDir": "./dist/",
    "strict": true,
    "target": "ESNext",
    "lib": ["DOM", "ESNext"],
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

A first index.ts to make sure everything is in the right place.

const monacoParent = document.getElementById("monaco") as HTMLDivElement;
const statusParent = document.getElementById("status") as HTMLDivElement;

monacoParent.innerText = "Monaco";
statusParent.innerText = "Status bar";

Change some scripts to ensure this is built

"scripts": {
  "build": "npm run clean && npm run build:html && npm run build:ts",
  "build:html": "cp src/index.html dist",
  "build:ts": "tsc",
  "watch": "tsc --watch"
}

And add a script line to index.html

    </div>
    <script src="./index.js"></script>
</body>
</html>

Refreshing the page now shows that the inner text of the two divs has the expected text.

Monaco

Ok, now I have a page and some script to run in it, let’s add Monaco. I’m going to load Monaco using the AMD loader script. This allows me to avoid webpack etc. and to load from unpkg.

    <script src="https://unpkg.com/monaco-editor@0.50.0/min/vs/loader.js"></script>
    <script>
      const baseUrl = "https://unpkg.com/monaco-editor@0.50.0/min/";
      require.config({paths: { vs: `${baseUrl}/vs` }});
      require(['vs/editor/editor.main', 'index'], (monaco, index) => {
          window.monaco = monaco;
          index.main();
      });
    </script>

There’s a couple of things going on here.

  1. loader.js is sourced directly, this makes sure that require is available.
  2. I configure require to use vs to point to the monaco editor package from unpkg. I use baseUrl to make it easier to switch versions, or better yet, to use a locally hosted copy of monaco-editor — it’s already in node_modules/monaco-editor, it’s more a question of where and how to host it, and for simplicity I’m loading it from a CDN
  3. I also require 'index' which will import our index.ts, rather than directly referencing index.js in a script element, which we now expect to export a main function.

I need to modify tsconfig.json to use AMD modules

    "module": "AMD",
    "moduleResolution": "Node",
    "esModuleInterop": true

And the updated index.ts

import type monaco from "monaco-editor";

declare global {
  interface Window {
    monaco: typeof monaco;
    getMonaco: () => Promise<void>;
  }
}

export const main = () => {
  const monacoParent = document.getElementById("monaco") as HTMLDivElement;
  const statusParent = document.getElementById("status") as HTMLDivElement;

  statusParent.innerText = "Status bar";

  const editor = window.monaco.editor.create(monacoParent, {
    value: "Hello, Monaco!",
    language: "text",
  });
};

Noteworthy stuff going on here:

  • I’m using import type not import, this ensures that typescript understands not to take a direct dependency on monaco-editor, just to pull the types in.
  • declare global — This let’s us reference window.monaco, which is also where vim-monaco expects to find it.
  • Creating the editor object is very simple!

This works, but needs to be prettified a little, so I add a stylesheet, main.css

body {
    margin: 0;
}
.container {
    height: 100vh;
    width: 100vw;
    overflow: hidden;
    display: grid;
    grid-template-rows: calc(100vh - 1.25em) 1.25em;
}
#monaco { 
  overflow: hidden;
}
#status {
    background-color: lightgray;
    min-height: 1.25em;
    font-family: monospace;
    align-content: center;
}

A small update to index.html in the head

    <link rel="stylesheet" href="main.css" />

and to package.json, in "scripts"

    "build:html": "cp src/index.html dist && cp src/main.css dist",

This now puts monaco in a page, with a placeholder for a status bar. Let’s add a vim mode.

VIM Mode

Adding this to the code is pretty simple, index.ts now looks like

import type monaco from "monaco-editor";
import type vim from "vim-monaco";

declare global {
  interface Window {
    monaco: typeof monaco;
    vim: typeof vim;
    getMonaco: () => Promise<void>;
  }
}

export const main = () => {
  const monacoParent = document.getElementById("monaco") as HTMLDivElement;
  const statusParent = document.getElementById("status") as HTMLDivElement;

  const editor = window.monaco.editor.create(monacoParent, {
    value: "Hello, Monaco!",
    language: "text",
  });

  const statusbar = window.vim.makeDomStatusBar(statusParent, () =>
    editor.focus()
  );
  const vimMode = new window.vim.VimMode(editor, statusbar);

  statusbar.toggleVisibility(true);
  vimMode.enable();
};
  • I’ve added a vim object to the global window object,
  • Created a status bar, using a default implementation within vim-monaco — accessed with makeDomStatusBar,
  • Created the VimMode instance,
  • Displayed the status bar
  • Enabled vim mode.

I need to make some changes to index.html to load things in the correct order.

    require(['vs/editor/editor.main'], (monaco) => {
        window.monaco = monaco;
        require(['vim-monaco/vim-monaco.umd', 'index'], (vim, index) => {
            window.vim = vim;
            index.main();
        }, (err) => {
            console.error('Error loading vim-monaco', err);
        });
    }, (err) => {
        console.error('Error loading monaco:', err);
    });

And we have an editor with a working VIM mode.

Tweaks

If you enter a command or search (: or /), then the input element feels ugly to me. I add a style for input inside the status element

#status input {
  font-family: monospace;
  border: none;
  background: none;
  outline: none;
}

If you resize the window, then the editor needs to know that it needs to resize also, so I add the following to index.ts

  window.addEventListener("resize", () =>
    editor.layout({
      width: monacoParent.offsetWidth,
      height: monacoParent.offsetHeight,
    })
  );

System Interaction

Currently there is no way to load or save a file…

I’m going to use the modern file system access browser features, so I need to add their types

$ npm i -D @types/wicg-file-system-access

and add the following to tsconfig.json in compilerOptions

    "types": ["@types/wicg-file-system-access"]

Loading a file

  vimMode.addEventListener("open-file", () =>
    showOpenFilePicker()
      .then((handles) => handles[0].getFile())
      .then((file) =>
        file.text().then((text) => {
          editor.setValue(text);
          window.monaco.editor.setModelLanguage(editor.getModel()!, file.type);
          monacoParent.setAttribute('data-filename', file.name);
        })
      )
      .catch((err) => console.error("Error opening file:", err))
  );

I could simply call editor.setValue with the text of the file, but I also set the model language to the mime type, which hopefully gets the correct language model.

I also stash the filename in a data attribute. This is a quick and dirty solution, so that when we save the file we know what it was called when it was loaded.

Saving a file

  vimMode.addEventListener("save-file", ({ filename }) =>
    showSaveFilePicker({
      suggestedName:
        filename.trim() ||
        monacoParent.getAttribute("data-filename") ||
        "untitled",
    })
      .then((handle) => handle.createWritable())
      .then((writable) => {
        const blob = new Blob([editor.getValue()]);
        return writable.write(blob).then(() => writable.close());
      })
  );

This uses the filename from the :save command if provided (both :write and :save raise this event), and if that is not populated, the value from the data attribute, and failing that, simply 'untitled' — this is only a suggestion to the browser anyway.

Wrapping up

This gives us an in-browser editor with a basic VIM mode in about 120 lines:

  • 60 lines of TypeScript,
  • 35 lines of HTML, and
  • 25 lines of CSS

It has some pretty heavy external dependencies, an empty cache reload will pull in 1.4 MB, the vast majority of which is from the monaco-editor package in editor.main.js (1.1MB) and workerMain.js (133 kB). vim-monaco is another 42 kB, small in comparison, but non-trivial.

As mentioned above, you can try it for yourself at pollrobots.com/monaco-and-vim and see the source code at github.com/pollrobots/monaco-and-vim