blog

Building a 21kb code editor

Brayden Wilmoth

Brayden WilmothMar 21, 2024

4 lines of HTML
50 lines of CSS
128 lines of Javascript

Today's world is built on code. Behind that code is oftentimes a very robust code editor to assist us on our missions, but with them typically come limitations. For example with us at Outerbase we wanted to have a built-in code editor within our very own product but it's not like you can simply drag and drop your favorite IDE directly into a website for all to enjoy. You can find suitable solutions such as Monaco or Lexical that can tackle most problems – but again they come with quite the bloat and complex learning curve when it comes to tailoring it to the specialized needs of your product.

After trying both Monaco & Lexical we found those solutions both too bulky and cumbersome. Monaco when used in our web project took a noticeable amount of time for it to load, was hard to customize tokens with specialized formatting, and ultimately couldn't easily be wrapped in our Tauri app (for native app distribution on Mac and Windows) due to its use of web workers.

So... we set out to build our own. The goal was to keep it lightweight, easy to customize, and barebones. If you want to build your very own code editor with syntax highlighting and keyboard commands for only 21kb in size and fewer than 200 lines of code then you've come to the right place.

Pre-Requisites

Building this project is not complex at all and as long as you have an understanding of basic web development then you should have no trouble creating your own code editor. Below is a list of the technologies we'll be using.

  1. Web component (HTML / CSS / Javascript)

  2. Webpack

  3. PrismJS

We're building this as a web component so it can be used anywhere without any framework lock in. Web Components, for those who don't know, allow developers to write HTML/CSS/Javascript as an exportable module fully encapsulated and easily imported into any web project just by using its custom HTML tag anywhere you'd write HTML such as <outerbase-editor />.

For our purposes to allow file size shrinking and wrap all our code into a single file we're going to use Webpack.

To provide syntax highlighting to our editor we will be using PrismJS to do the heavy lifting for us. We could (and trust me, I have) gone down the route of doing our own regex to style our code tokens but it's way more effort than it's worth and Prism is very lightweight.

Setup Project

Nearly all of our effort will be spent in a single Javascript file building our web component, but it also helps to test it inside an HTML file as well. Go ahead and create a new project folder and add two files to it:

  1. index.html

  2. component.js

Our HTML file is very barebones. Let's add this code to it.

<html>
<head>
    <script src="./component.js" defer></script>
</head>

<body>
    <outerbase-editor></outerbase-editor>
</body>
</html>

This file will strictly be used for a testing grounds as we add functionality to our web component. In the next section we'll go about starting to create the web component that has been included as a script tag in the code above.

Create a Web Component

With almost all of our logic living in our component.js file, we'll step through this bit by bit, starting off by just adding the bare necessities of our web component to our file. Add the below code to your file.

var templateEditor = document.createElement("template");
templateEditor.innerHTML = `
<style>
    // CSS Styles will go here
</style>

<div id="container">
    <!-- HTML elements will go here -->
</div>
`;

class OuterbaseEditor extends HTMLElement {
    static get observedAttributes() {
        return [];
    }

    constructor() {
        super();

        this.shadow = this.attachShadow({ mode: "open" });
        this.shadowRoot.innerHTML = templateEditor.innerHTML;
    }
}

window.customElements.define("outerbase-editor", OuterbaseEditor);

For those of you who have not yet worked with web components, you'll be pleasantly surprised to learn the above code is all you need to define your own custom HTML tag with logical and visual components coupled together. If you open your index.html file then you should see in your DOM that <outerbase-editor> exists and is being rendered – but don't fret when you don't see anything on the canvas itself because we're not returning any visual HTML at this point.

Layout Editor

There's an interesting bit to know about how our code editor will work. Instead of using a single DOM element to control both the text editing and the syntax highlighting, we break it into two elements that are pixel perfectly overlapping one another. On the top layer we have our <textarea> element to handle all the users input all while the entirety of its contents are completely transparent. Below that we have a <code> tag that handles all the syntax highlighting and displaying the text to the users.

Why do we do this you may ask? If we combined the two functional pieces into one DOM element, then as a user types and our syntax highlighting code tries to update the formatting our cursor would lose position because a re-render is required. When we split it up so each piece has a single responsibility then we start to see it working in harmony.

Update our HTML id="container" div to have two children elements.

<div id="container">
    <pre><code></code></pre>
    <textarea class="editor"></textarea>
</div>

It's clear what our textarea is doing (accepting user input), but our <pre><code> might look a bit strange at first. Once we begin implementing PrismJS in our project for syntax highlighting we'll realize that this is a requirement for the way they structure their CSS – but it's also the correct element choices for displaying code.

Let's begin adding some layout and styles to our DOM structure by replacing the CSS placeholder text with our code below.

<style>
    #container {
        height: 100%;
        width: 100%;
        margin: 0;
        overflow: scroll;
        font-family: monospace;
    }

    textarea {
        resize: none;
        outline: none;
        overflow: hidden;
    }

    pre, textarea, code {
        padding: 0 !important;
        margin: 0 !important;
        min-height: 100%;
        min-width: 100%;
        position: absolute;
        top: 0px;
        left: 0px;
        width: 100%;
        height: 100%;
        background-color: transparent !important;
        line-height: 16px !important;
        font-size: 13px !important;
    }

    .editor {
        color: transparent;
        caret-color: black;
        border: none;
    }

    pre {
        overflow: visible !important;
    }

    code {
        pointer-events: none;
        color: black;
    }

    textarea, code {
        white-space: pre;
        overflow-wrap: none;
        word-wrap: normal;
    }
</style>

Most of what is happening above is allowing our two DOM elements to perfectly overlap one another by assuring they share identical font, size and heights throughout.

Why all of the ugly !important properties? It's a quick easy way for us to override what Prism is going to eventually try enforcing, making sure our rules win out.

Data Communication

A key component of having a code editor is to be able to pass data into it, and pass data back out of it to our parent host when updates occur. Earlier in our web component we added an observedAttributes array, which is standard for web components. This allows our component to know which attributes we should be observing and reacting to. Go ahead and update our array to support two attributes.

static get observedAttributes() {
    return [
        "code",
        "language"
    ];
}

For our purposes the code attribute will allow users to pass any code that should be pre-loaded into the editor when loaded, and language will be used to tell the editor what type of programming language syntax we should load in for highlighting the appropriate tokens. In this tutorial we'll only be covering Javascript as a language but adding additional languages is easy.

Now we need our web component to update when it receives these two values. Web components provide us with an attributeChangedCallback lifecycle function to track when attribute values change and allow us to execute custom logic. This allows us to populate the code into our <textarea> when it's passed in, and then add the appropriate class for what programming language we're looking to syntax highlight for.

attributeChangedCallback(name, oldValue, newValue) {
    if (name === "code") {
        this.shadow.querySelector(".editor").value = newValue;
    }

    if (name === "language") {
        this.shadow.querySelector("code").className = `language-${newValue}`;
    }
}

To pass these in let's go back to our index.html file and add two attributes to our custom HTML element:

<outerbase-editor
    code="// My code comment goes here..."
    language="javascript"
></outerbase-editor>

Passing data into our web component is as simple as that. Let's close the loop by seeing how we can have our web component send data events back to our HTML file for us to observe.

If you were to go to your browser and refresh at this point it may appear to be a blank page, and you'd probably expect to see a bit more, but trust me it's all there just invisible. To test it out just select all the text on the page, copy it to your clipboard and paste it somewhere and you should see the same value that we passed into the code attribute of our web component.

While we're in our index.html file let's go ahead and add the Javascript logic that will receive events from our custom HTML element. You can add this code below our closing </body> tag.

<script>
    const editor = document.querySelector('outerbase-editor');

    editor.addEventListener('outerbase-editor-event', (event) => {
        // Access the code from the event detail
        console.log('Code updated:', event.detail.code);
    });
</script>

Great! When our web component emits an event our host file can now have access to the data. Finishing off this process we need to make sure our web component throws that event so the event listener has something to listen to. Going back into your component.js file

constructor() {
    // ... 

    this.editor = this.shadow.querySelector(".editor");
    this.visualizer = this.shadow.querySelector("code");
}

connectedCallback() {
    this.editor.addEventListener("input", (e) => {
        this.visualizer.innerHTML = e.target.value;
        this.dispatchEvent(new CustomEvent('outerbase-editor-event', { bubbles: true, composed: true, detail: { code: this.editor.value } }));
    });
}

HANG IN THERE.. I know this is a lot to take in but I promise you altogether once we're done this is roughly only 200 lines of code.

Adding PrismJS

Syntax highlighting is one of the most important features for a code editor to have. Creating our own syntax highlighting logic doesn't provide much value when a community of people have already taken the time to do the hard work for us without any unnecessary frills.

Visit the PrismJS download page to get a Javascript & CSS file (make sure you download both of them from the bottom of the page).

If you click the link above you will have everything pre-selected for you that you need to support Javascript syntax highlighting. Once you have downloaded both files, move them into a folder in your project directory such as ./prism/ as

  • prism.js

  • prism.css

With these files present now we need to let our web component know to load them in. This step will be undone when we integrate webpack, but in order for us to see our changes in our index.html page we need to add this to the bottom of our constructor() function. It's important to know that for the first style object you must copy and paste your CSS file contents into the string value here.

Just open up your ./prism/prism.css file, copy all of the contents in there to your clipboard and replace it in the style.textContent value as shown below.

constructor() {
    // ...

    // Add Prism CSS
    const style = document.createElement('style');
    style.textContent = `COPY & PASTE YOUR CSS FILE CONTENTS HERE`;
    this.shadow.appendChild(style);

    // Add Prism JS
    const script = document.createElement('script');
    script.src = "./prism/prism.js";
    script.onload = () => {
        // When the script loads, syntax highlight initial code
        this.redrawSyntaxHighlighting();
    };
    this.shadow.appendChild(script);
}

And now we need to tell our component to perform syntax highlight operations when a valid event occurs (e.g. initial page load or user input).

connectedCallback() {
    this.editor.addEventListener("input", (e) => {
        // ...
        this.redrawSyntaxHighlighting();
    });
}

attributeChangedCallback(name, oldValue, newValue) {
    // ...

    // When an input value changes, re-render our component
    this.redrawSyntaxHighlighting();
}

redrawSyntaxHighlighting() {
    this.visualizer.innerHTML = this.editor.value;

    try {
        Prism.highlightElement(this.visualizer);
    } catch (error) { }
}

When you reload your index HTML file you should successfully see your very own syntax highlighting web component code editor.

Resizing Code Editor

When edits are made to our code editor we need to resize our DOM elements accordingly to fit the content. The below function allows us to calculate the correct dimensions whenever a change is detected, so let's add this in.

adjustTextAreaSize() {
    // Height is number of lines * line height
    const lineHeight = parseFloat(getComputedStyle(this.editor).lineHeight);
    const lineCount = this.editor.value.split("\n").length;
    const height = lineCount * lineHeight;

    // Set height of elements based on contents
    this.editor.style.height = `${height}px`;

    // Set width of elements based on contents
    const width = Math.max(this.editor.offsetWidth + 1, this.editor.scrollWidth) + 'px';
    this.editor.style.width = width;
    this.visualizer.style.width = width;
}

After our new function is defined we need to ensure we call it when necessary, so add a call to that function in our redrawSyntaxHighlighting function.

redrawSyntaxHighlighting() {
    this.visualizer.innerHTML = this.editor.value;
    this.adjustTextAreaSize() // <-- Add this line of code

    try {
        Prism.highlightElement(this.visualizer);
    } catch (error) { }
}

Now any time a user performs an input event we'll recalculate the height and width of our contents and resize our DOM elements appropriately.

Supporting Custom Keyboard Shortcuts

A glorified <textarea> doesn't make for the greatest code editor for programmers. We need to help supercharge workflows that programmers are accustom to such as CMD + ] for indenting, CMD + / for adding comments, hitting Enter key and maintaining the users indentation, and much more.

Thankfully since we own all of the logic of our code editor the sky is the limit on what we can support. We'll show you how to support some of the shortcut keys mentioned above and you can apply that same logic to continue implementing any additional shortcuts you'd like.

What we need is an additional event listener to wait for users to perform specific key presses or combination key presses and then override default behavior with our own. It's quite simple.

Below shows an example of supporting the Tab key to indent. The second supported shortcut in the snippet below is when a user presses the Enter key, if they are already indented, it will maintain their indentation level.

connectedCallback() {
    // ...

    this.editor.addEventListener("keydown", (e) => {
        // Support "Tab" key code indentation
        if (e.key === "Tab") {
            e.preventDefault(); // Stop the default tab behavior
            var start = e.target.selectionStart;
            var end = e.target.selectionEnd;

            // Set textarea value to: text before caret + tab + text after caret
            e.target.value = e.target.value.substring(0, start) +
                                "    " + // This is where the tab character or spaces go
                                e.target.value.substring(end);

            // Put caret at right position again
            e.target.selectionStart =
            e.target.selectionEnd = start + 4; // Move the caret after the tab
        }
        // Support ability to maintain indentation level on new line
        else if (e.key === "Enter") {
            e.preventDefault(); // Prevent the default enter behavior

            var start = e.target.selectionStart;
            var end = e.target.selectionEnd;
            var beforeText = e.target.value.substring(0, start);
            var afterText = e.target.value.substring(end);

            // Find the start of the current line
            var lineStart = beforeText.lastIndexOf("\n") + 1;
            // Calculate the indentation of the current line
            var indentation = beforeText.substring(lineStart).match(/^(\s*)/)[0];

            // Insert newline and the same indentation
            e.target.value = beforeText + "\n" + indentation + afterText;

            // Update cursor position
            var newPos = start + 1 + indentation.length; // Move cursor after the new line and indentation
            e.target.selectionStart = e.target.selectionEnd = newPos;
        }
    });
}

In future installments of this series we'll go much more in depth about various types of shortcut commands and how to implement them. Some of them require more complex logic that's worth diving into deeper.

Webpack & Production Readiness

We have the basics and core functionality of our very own lightweight code editor in place and now it's time to bundle it up and start using it in our projects. To accomplish this we're going to use npm and webpack to minify our code and put it into a production ready file for us to use. In future installments of this series we'll add a lot more functionality to our code editor that spans outside of our component.js file and break logical pieces into their own files, and that's where webpack will help us out tremendously to compile it all down into a single file.

Before digging into that there are a couple of changes we need to make to our component.js file to get it ready for webpack compilation. First we need to add the export keyword in front of our class declaration.

export class OuterbaseEditor extends HTMLElement {
    // ...
}

IMPORTANT: While you're in there also, either comment out or remove the code in the constructor() that adds in our Prism script. Instead we will add an import to the top of our code. Remove the code shown below.

// REMOVE THIS BLOCK OF CODE FROM YOUR CONSTRUCTOR!!!
// Add Prism JS
const script = document.createElement('script');
script.src = "./prism/prism.js";
script.onload = () => {
    // When the script loads, syntax highlight initial code
    this.redrawSyntaxHighlighting();
};
this.shadow.appendChild(script);

And now add in the import at the top of your component.js file. This is a more proper way to import our files, especially when webpack is at play to help us link and compile them into one.

import './prism/prism.js'

Finally. All of our coding is done. Let's wrap this project up quickly by setting up our webpack and project configuration to allow us to build it.

Create two new files in our project root:

  1. package.json

  2. webpack.config.js

The contents of these files are nothing to write home about so we won't cover what's happening much.

// package.json
{
    "name": "universe",
    "version": "1.0",
    "description": "",
    "main": "dist/universe.js",
    "module": "dist/universe.js",
    "scripts": {
        "test": "echo \"Error: no test specified\" && exit 1",
        "build-web-component-bundle": "webpack --mode production"
    },
    "files": [
        "dist/*"
    ],
    "keywords": [],
    "author": "",
    "license": "ISC",
    "devDependencies": {
        "@babel/core": "^7.23.5",
        "@babel/preset-env": "^7.23.5",
        "babel-loader": "^9.1.3",
        "html-webpack-plugin": "^5.5.3",
        "webpack": "^5.91.0",
        "webpack-cli": "^5.1.4"
    },
    "private": false
}

And next comes our webpack file.

// webpack.config.js
const path = require('path');

module.exports = {
  entry: './component.js', // Your main JavaScript file
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'universe.js',
    libraryTarget: 'umd', // This will make your library usable in various environments
    globalObject: 'this'
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env'],
          },
        },
        exclude: /node_modules/,
      },
    ],
  }
};

All that is left for us is to install our dependency packages and build our project with webpack and we're off to the races.

> npm install
> npm run build-web-component-bundle

Now open up your project folder and you should see a new dist folder that exists. Inside there a universe.js file that is a tiny 21kb of pure extensible gold.

YOU DID IT!

What's Next?

This is a very basic code editor with not a lot of features built into it. What's great about it is you now understand the code well enough to build it out as much as you want and have full scope of the code to do so.

We will cover many more subjects and continue to enrich our code editor in upcoming blog posts. Here is just a preview of the other parts we plan to cover:

  • Line numbers

  • Advanced keyboard shortcuts

  • Selected line background bar

  • Custom scrollbars

  • Theme support

  • and much, much more...

If there's anything in particular you'd like to see send us an email or join us on Discord!

What will you discover?

Whether you're a startup, small business, or global enterprise, we're here for you.

Get started for free