Monaco and VIM
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:
-
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
-
npm
$ npm init -y $ npm i -D typescript monaco-editor vim-monaco added 3 packages, and audited 4 packages in 13s found 0 vulnerabilities
-
vscode
$ echo '{ > "folders": [ > { "path": "." } > ] > }' > monaco-and-vim.code-workspace $ code monaco-and-vim.code-workspace
-
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.
loader.js
is sourced directly, this makes sure that require is available.- I configure require to use
vs
to point to the monaco editor package from unpkg. I usebaseUrl
to make it easier to switch versions, or better yet, to use a locally hosted copy of monaco-editor — it’s already innode_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 - I also require
'index'
which will import ourindex.ts
, rather than directly referencingindex.js
in ascript
element, which we now expect to export amain
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
notimport
, 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 referencewindow.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 globalwindow
object, - Created a status bar, using a default implementation within
vim-monaco
— accessed withmakeDomStatusBar
, - 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