Building a VSCode-like Editor
This is the code for the editor project as outlined in Ideas and Projects for 2021.
The idea is to Explore Electron, Monaco (the editor behind VS Code) and how they work together (or not) to build a smaller code editor with special features.
Some of the goals for the project (updated from the post to reflect new knowledge).
-
Use the code from the electron-esm-webpack example in the monaco-editor-sample repository
-
Switch the code to use Typescript
-
User the Monaco text editor as the core editor for the project. This way we leverage all the languages Monaco supports with specific emphasis on:
- Markdown
- CSS
- SCSS
- HTML
- XML
- Javascript
- Typescript
-
Research the best way to integrate Monaco into an Electron application
-
Use Electron's built-in menus and event handlers to interact with the native file system
-
Export Markdown to HTML
-
Allow packaging of content into zipped bundles, epub3, and web bundles
-
Research what it would take to use WASM modules inside an Electron application
Why Electron? #
Rather than create a web application and choosing a framework I want to explore how to bundle Monaco into a set of cross-platform (macOS, Windows, and Linux) without having to write the code myself.
Monaco Editor #
Monaco editor is the base for VSCode; as such you get a lot of features out of the box. For this project some of the features I'm most interested in are:
- Syntax Highlighting
- Support for all languages targeted in the project out of the box
- Intellisense and autocompletion for supported languages
- Extensibility to other languages
Getting Started #
Some things to understand before we start writing code.
Electron uses two main files (taken from ELectron's documentation).
-
The Main Process (
main.js
)- The Main process creates web pages by creating
BrowserWindow
instances. EachBrowserWindow
instance runs the web page in its Renderer process. When aBrowserWindow
instance is destroyed, the corresponding Renderer process gets terminated as well - The Main process manages all web pages and their corresponding Renderer processes
- The Main process creates web pages by creating
-
The Renderer process (
renderer.js
)- The Renderer process manages only the corresponding web page. A crash in one Renderer process does not affect other Renderer processes
- The Renderer process communicates with the Main process via IPC to perform GUI operations on a web page. Calling native GUI-related APIs from the Renderer process directly is restricted due to security concerns and potential resource leakage
Setting everything up #
Because this is a Node-based project we need to initialize it as such. The first step is to create a package.json
file.
The command I use to create the package file with all the defaults I use on my projects is
npm init --yes
npm i -D electron
just to be on the safe side, update the script section of package.json
as follows:
"scripts": {
"start": "electron ."
},
This will make it easier to run the project with npm start
or npm run start
rather than installing electron globally or digging through the node modules hierarchy with node_modules/.bin/electron
.
The last remaining files are what's actually going to run the app.
main.js
#
The first file is main.js
. This will run the main tasks of the app like creating windows and allowing us to quit the application.
I've broken it down by functionality.
We first import app
and BrowserWindow
from the Electron package and the built-in path
package from Node.js.
const {app, BrowserWindow} = require('electron')
const path = require('path')
The first function will create a browser window and load the application's index.html
file.
The webPreferences
child of BrowserWindow indicates what features we want to use for these web pages. For this example, I've added nodeIntegration: true
to make sure I can run Node code in this application.
For more information about webPreferences
and its values, see the webPreferences
entry in new BrowserWindow([options])
function createWindow () {
// Create the browser window.
const mainWindow = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
nodeIntegration: true
}
})
// and load the index.html of the app.
mainWindow.loadFile('index.html')
}
The whenReady
method will be called when Electron has finished initialization and is ready to create browser windows. I think of it as equivalent to the DOMContentLoaded
event.
Some APIs can only be used after this event occurs. In this example, we cannot create run createWindow()
until the application is ready.
Multiple actions can trigger the activate
event, such as launching the application for the first time, attempting to re-launch the application when it's already running, or clicking on the application's dock or taskbar icon. In this example, if there are no windows present when we trigger the event, then create a new one.
app.whenReady().then(() => {
createWindow()
app.on('activate', function () {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
The windows-all-closed
event quits the application when all windows are closed for Windows and Linux systems. In macOS, it's common for applications and their menu bar to stay active until the user quits explicitly with Cmd + Q
.
app.on('window-all-closed', function () {
if (process.platform !== 'darwin') app.quit()
})
main.js
can include the rest of your app's specific main process code. You can also put them in separate files and require them here.
The second file is renderer.js
. It is optional, if it's not present then the renderer processes will work from the HTML file we load in main.js
, but I choose to include it even if it's empty
Other examples use the index.html file by itself and ignore renderer.js
, I choose not to.
The only thing that's important on this page is the Content-Security-Policy
meta element. This will dictate the things that the page, and therefore the app, can do.
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Hello World!</title>
<meta http-equiv="Content-Security-Policy"
content="script-src 'self' 'unsafe-inline';" />
</head>
<body style="background: white;">
<h1>Hello World!</h1>
<p>
We are using node <script>document.write(process.versions.node)</script>,
Chrome <script>document.write(process.versions.chrome)</script>,
and Electron <script>document.write(process.versions.electron)</script>.
</p>
</body>
</html>
Integrating Electron and Monaco #
Integrating the Monaco editor into an Electron application requires us to configure WebPack. We begin by installing the packages necessary to get WebPack working.
npm i -D webpack \
webpack-cli \
file-loader\
style-loader\
css-loader
To install the Monaco editor, just install the editor using NPM.
npm i monaco-editor
The only special thing to note on the WebPack configuration is the multiple entry points for both the Electron Renderer and for each of the Monaco workers.
const path = require('path');
module.exports = {
mode: 'development',
entry: {
'app': './renderer.js',
'editor.worker': 'monaco-editor/esm/vs/editor/editor.worker.js',
'json.worker': 'monaco-editor/esm/vs/language/json/json.worker',
'css.worker': 'monaco-editor/esm/vs/language/css/css.worker',
'html.worker': 'monaco-editor/esm/vs/language/html/html.worker',
'ts.worker': 'monaco-editor/esm/vs/language/typescript/ts.worker',
},
output: {
globalObject: 'self',
filename: '[name].bundle.js',
path: path.resolve(__dirname, 'dist'),
},
module: {
rules: [
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
},
{
test: /\.ttf$/,
use: ['file-loader'],
},
],
},
};
Configuring the editor is done in the renderer process. We first require the monaco-editor
NPM module.
We then instantiate the editor environment in self.MonacoEnvrionment
. This sets up all the workers for the different languages the editor provides.
const monaco = require('monaco-editor');
self.MonacoEnvironment = {
getWorkerUrl: function(moduleId, label) {
if (label === 'json') {
return './json.worker.bundle.js';
}
if (label === 'css' || label === 'scss' || label === 'less') {
return './css.worker.bundle.js';
}
if (label === 'html' || label === 'handlebars' || label === 'razor') {
return './html.worker.bundle.js';
}
if (label === 'typescript' || label === 'javascript') {
return './ts.worker.bundle.js';
}
return './editor.worker.bundle.js';
}
};
The next step is to create the editor instance. The monaco.editor.create
method takes two parameters, the element where we want to insert the editor and an object with the value of the initial code and the language for this code extract.
monaco.editor.create(document.getElementById('container'), {
value: ['function x() {', '\tconsole.log("Hello world!");', '}'].join('\n'),
language: 'javascript'
});
Finally, we have to modify the index.html
so it looks like this:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'unsafe-eval' file: 'sha256-AcqnkP3xWRYJaQ27hijK3b831+qsxvzEoSYt6PfGrRE='; style-src 'unsafe-inline' file:; font-src file:; img-src data: file:"
/>
<style>
#container {
width: 1600px;
height: 1200px;
border: 1px solid #ccc;
}
</style>
</head>
<body>
<div id="container"></div>
</body>
<script src="./app.bundle.js"></script>
</html>
What's next? #
Now that we have Monaco wired to an Electron application we can start doing work on building the features we outlined at the beginning of the post. Some of these include:
-
Create custom menus
-
Basic file management functionality
- Create new windows
- Open
- Save
- Save
- Close
- Delete
Once that functionality is complete we will have a working editor that will be good enough for daily use. From there we can look at more advanced functionality