Experiments with CodeMirror

Published on 2026/02/14

Recently, I’ve been working on more and more complex web applications and one common component that many of these applications would benefit from is a nice text/code editor.

To not complicate my life even more, I’ve always used simple and sad textareas without any advanced features like syntax highlighting and multiple cursors (or even just auto-close parentheses or brackets).

But editors are a very important part of the user experience in many applications, and Most LLMs today suggest to just use Monaco™, which is a great editor, but it is also very heavy and not easily customizable. I want to have more control over the editor itself and I would also like to extend the editor in various ways, for example by adding custom decorations or other features that are not easily achievable with Monaco.

Various text editor options

Just for the sake of completeness, let’s quickly go through some of the options available for text editors on the web. This is not an exhaustive list, but it should give you an idea of the landscape and the trade-offs between different options.

Just a textarea

This is the last trademarked thing in this post and the simplest option available on the web, this doesn’t require any external libraries, copy-paste and undo/redo work out of the box. This is easy to set up and one can easily add features like auto-resize.

The only feature I generally add is vertical auto-resizing, I do the following

textarea {
    /* To keep the whole textarea 
       in the viewport */
    max-height: 75vh;

    resize: none;

    /* Disable wrapping, nice for
       code editing and this is 
       a must for this technique */
    white-space: pre;
    overflow-wrap: normal;
    overflow-x: scroll;
}
<textarea
    value={content}
    onInput={e => setContent(e.currentTarget.value)}
    rows={Math.max(3, content.split('\n').length)}
/>

This article is written using MDX and has meny interactive examples, here how the auto-resize textarea looks like in practice:

Another technique is to just use the vertical scroll value of the <textarea>

function autoResizeTextarea(textarea: HTMLTextAreaElement) {
    textarea.style.height = 'auto'
    textarea.style.height = `${textarea.scrollHeight}px`
}

That has the following effect, this works even without word wrapping but can be a bit janky due to the layout shifts caused by the height changes

If you only need a single input field with autocomplete, let’s always remember that HTML has the native <datalist> element that can be used to provide suggestions for an <input> or <textarea> element.

Without too much work this can be used as a very nice base for the common “tags input” or “multi select” component, keep a input with a datalist with the already available tags and when the user presses enter, add the current value to the list of selected tags and clear the input.

Other editor libraries

Now let’s talk about the main libraries available

A notable mention is CodeJar, a lightweight editor library that uses “the overlay trick” to support syntax highlighting and uses a contenteditable element for input and reimplements editor behavior from scratch. It is very easy to set up and you can also provide syntax highlighting through Prism.js, Highlight.js, or a custom user-provided function.

Most text editor libraries grew organically over time, adding features and complexity in an ad-hoc manner. CodeMirror 6 is the latest iteration of the CodeMirror project, a complete rewrite that was designed from scratch with a modular architecture in mind that separates state management from view rendering.

Codemirror 6

Why I am writing this post? Many of the applications I worked on recently could benefit from AI code generation features. I think that the chat interface model isn’t always ideal and I prefer having the LLMs update a document by regenerating it completely or by using tool-based editing.

In both cases, users need to see the diff between the old and the new version of the document and be able to accept or reject each change. For my use cases, this is currently the most intuitive and precise way to handle external changes while editing both code or prose.

CodeMirror already provides an extension called codemirror/merge that offers side-by-side and unified diff views, making it well-suited for this use case. In this article, we’ll explore the basics of CodeMirror 6 and how to leverage the merge extension to build a simple editor with a review mode for managing changes after an external update.

The only problem I have with CodeMirror 6 is that the documentation is very sparse, there are many examples but they are not very detailed and don’t explain the core concepts of the library.

Documentation quadrants

In terms of the four documentation quadrants, CodeMirror 6 has a lot of “how-to” and “reference” material and a system guide that explains the core concepts, but it lacks good tutorials that incrementally introduce concepts and features in a practical way. This was one of the hardest things for me while learning it, and I hope this article can be a good starting point for anyone interested in using CodeMirror 6 for their projects.

By the way, I also tried to ask Claude and some other models to write this extension for me, but they all failed to produce a working implementation. I think the functional architecture of CodeMirror 6 is not very intuitive for them, and being a niche library with sparse documentation doesn’t help either.

Basic setup

As most text editor libraries, CodeMirror (from now on I will omit the 6) can be used with a very simple setup. Just provide a DOM element to mount the editor onto and give it some initial content. Here is a minimal example from the official documentation:

import { basicSetup } from 'codemirror'
import { EditorView } from '@codemirror/view'

const view = new EditorView({
    parent: document.body,
    doc: 'Start document',
    extensions: [basicSetup],
})

This code is correct but this is already lying a bit about the architecture, but we’ll get to this later. Differently from other editors, accessing and modifying the content of the editor is more involved than just reading or writing a .value property.

To get the current content of the editor, you can use view.state.doc.toString(). To modify it programmatically, you need to dispatch a transaction with all the changes you want to apply (a change is a range of text to replace and the new text to insert in its place).

Here is an example of replacing all the content of the editor with a new string (more precisely a new document, this is the representation used internally by CodeMirror for the content of the editor, it has some optimizations for large documents and other features, but you can just think of it as a string for now and most places that expect a Text will also accept a string and convert it for you):

// Get current content
console.log(view.state.doc.toString())

...

// Replace all content
view.dispatch({
    changes: {
        from: 0,
        to: view.state.doc.length,
        insert: 'New content'
    },
})

Let’s get back to the lie from before, the doc and extensions parameters are actually just shorthands for creating an initial state for the editor, here is a more explicit way to write the same code as above:

import { EditorState } from '@codemirror/state'
import { EditorView, basicSetup } from '@codemirror/view'

const state = EditorState.create({
    doc: 'Start document',
    extensions: [basicSetup],
})

const view = new EditorView({
    parent: document.body,
    state,
})

The first thing to note here is that state objects are immutable, you cannot modify them directly. On the other hand, state objects have utility methods to create transaction objects that represent changes to the state. You can then dispatch these transactions to an EditorView to apply the changes. This is a very powerful and flexible way to manage the state of the editor, but it can be a bit overwhelming at first.

To recap, the core concepts of CodeMirror 6 are:

These are separated at the module level as well, with the @codemirror/state, @codemirror/view, and @codemirror/commands packages.

Another important concept is that of Facets. They are the main way CodeMirror lets extensions interact with each other and with the editor. Here are some examples from the documentation:

The idea behind facets is that most types of extension points allow multiple inputs, but want to compute some coherent combined value from those. How that combining works may differ.

  • For something like tab size, you need a single output value. So that facet takes the value with the highest precedence and uses that.
  • When providing event handlers, you want the handlers as an array, sorted by precedence, so that you can try them one at a time until one of them handles the event.
  • Another common pattern is to compute the logical or of the input values (as in allowMultipleSelections) or reduce them in some other way (say, taking the maximum of the requested undo history depths).

Source: https://codemirror.net/docs/guide/#facets

It’s still not completely clear to me how facets work behind the scenes, what I got is that they are the thing that is cached and recomputed only when the relevant inputs of a facet change (i.e. signals, but like React useEffect they take an explicit dependency array). So they are the “nodes” in the DAG of cached computations that CodeMirror uses to optimize the computations needed to update the view of the editor.

In a given configuration, most facets tend to be static, provided only directly as part of the configuration. But it is also possible to have facet values computed from other aspects of the state.

let info = Facet.define<string>()
...
extensions: [
    info.of('hello'),
    info.compute(['doc'], state => `lines: ${state.doc.lines}`),
],
...
console.log(state.facet(info))
// ["hello", "lines: 2"]

[…] facet values are only recomputed when necessary, so you can use an object or array identity test to cheaply check whether a facet changed.

The Unified Merge Extension

Here is a basic example of how to setup a simple editor with the unified merge extension:

import { EditorState, EditorView, basicSetup } from 'codemirror'
import { unifiedMergeView } from '@codemirror/merge'

const view = new EditorView({
    parent: document.body,
    doc: 'one\ntwo\nthree\nfour',
    extensions: [
        basicSetup,
        unifiedMergeView({
            original: 'one\n2\nthree\n4',
            allowInlineDiffs: true,
        }),
    ],
})
Try this out!

By default this view shows the diff between the “current” content and an “original” provided content. All changes are made to the “current” content, so to update the diff you just need to dispatch updates as always and the diff view will update accordingly.

For example to add a simple regex find and replace tool, you can do something like this:

function findReplaceTransaction(
    state: EditorState,
    findText: string,
    replaceText: string,
): ChangeSpec[] {
    const changes: ChangeSpec[] = []
    const regex = new RegExp(findText, 'g')

    const docText = state.doc.toString()
    for (const match of docText.matchAll(regex)) {
        changes.push({
            from: match.index,
            to: match.index + match[0].length,
            insert: replaceText,
        })
    }

    return changes
}

...

view.dispatch({
    changes: findReplaceTransaction(view.state, findText, replaceText)
})

Try to use the find and replace tool and change “o” to “O”

This works fine as a diff editor, but I would like to make this more like the VSCode code review interface. After the user accepts or rejects all the changes I want the diff “mode” to be disabled so that the user can continue editing the document as normal. This is not the default behavior of the merge view, but we can achieve this by conditionally enabling and disabling the unifiedDiff extension.

For now let’s start from the problem of detecting when the user accepts or rejects a change in the merge view, and then we will use this information to disable the diff view when there are no more changes to review.

Intercepting accept/reject events

This can be achieved using an EditorView.updateListener extension that listens for transactions with the accept or revert user events which are emitted by the unified merge view. This was one of the things that wasn’t documented in the merge view documentation (I found this out by giving Gemini the whole @codemirror/merge repo and asking it how to detect this, and it was very helpful).

EditorView.updateListener.of(update => {
    const tr = update.transactions.find(
        tr => tr.isUserEvent('accept') || tr.isUserEvent('revert'),
    )

    if (tr) {
        const remainingChunks = getChunks(update.state)?.chunks.length
        const eventType = tr.isUserEvent('accept') ? 'accepted' : 'reverted'

        console.log(`Chunk ${eventType}! ${remainingChunks} remaining.`)
    }
})

The getChunks() function from @codemirror/merge takes the current editor state and returns the current diff chunks, allowing us to determine how many changes are left to review.

This will show a toast message when a chunk is accepted or reverted.

Compartments

The next step is to enable or disable the unified diff view based on the current state of the editor. We can achieve this by conditionally applying the unifiedDiff extension using compartments, which allow us to “reconfigure” parts of the editor configuration dynamically.

We can create a new “extension type” for this compartment as follows:

export const unifiedDiffCompartment = new Compartment()

Then we can instanciate this as an empty compartment using unifiedDiffCompartment.of([]) in the initial editor configuration, and later reconfigure it to enable the unified diff view when needed:

view.dispatch({
    effects: unifiedDiffCompartment.reconfigure(
        enabled ? [unifiedMergeView(...)] : []
    ),
})
Try to enter and exit the review mode

Let’s put it all together

Let’s now create a final editor that has all this pieces together. After some trial and error, I found that the best way to implement this is to trigger the “review mode” with a custom user event that I called review-changes. The original document of the merge view should be update only when first entering this review mode.

To solve this we can use a state field and a state effect to store and update the original document when first entering the review mode

const setOriginalDoc = StateEffect.define<Text | false>()

const originalDocField = StateField.define<Text | false>({
    create() {
        return false
    },
    update(value, tr) {
        console.log('originalDocField update:', value, tr)

        for (const effect of tr.effects) {
            if (effect.is(setOriginalDoc)) {
                console.log('Setting original document in state field')
                return effect.value
            }
        }

        if (value === false && tr.isUserEvent('review-changes')) {
            console.log('Storing original document for review mode')
            return tr.startState.doc
        }

        return value
    },
})

To handle the review mode logic, we can use a “transaction extender” that intercepts transactions and manipulates them before they are applied.

Here is the extension that implements this logic:

EditorState.transactionExtender.of(tr => {
    if (tr.isUserEvent('review-changes')) {
        return {
            effects: [
                unifiedDiffCompartment.reconfigure([
                    unifiedMergeView({
                        original: tr.state.field(originalDocField),
                        allowInlineDiffs: true,
                    }),
                ]),
            ],
        }
    }

    if (tr.isUserEvent('accept') || tr.isUserEvent('revert')) {
        if (getChunks(tr.state)?.chunks.length === 0) {
            return {
                effects: [
                    setOriginalDoc.of(false),
                    unifiedDiffCompartment.reconfigure([]),
                ],
            }
        }
    }

    return null
})

For sake of completeness, here is how the find and replace tool triggers the review mode

view.dispatch({
    changes: findReplaceTransaction(view.state, findText, replaceText),
    userEvent: 'review-changes',
})

And this is the final result, you can try to enter the review mode, accept or reject changes and see how the diff view is toggled accordingly.

The final result, try to enter the review mode by replacing “2” with “two” and “4” with “four”, then accept or reject the changes and see how the diff view is toggled accordingly.

If you want to see the full code for this example, you can check the appendix where I put a self-contained version of this review mode extension that you can copy-paste into your project and modify as needed.

Conclusion and Future Experiments

This post is already getting quite long, so I will just briefly mention some other experiments I’m working on I might write about in future posts, including some sneak peeks:


References

Appendix

Let me copy-paste a snippet

Here’s a self-contained version of the code for the review tool extension. You can just copy-paste this “micro-library” into your project and let LLMs modify it as needed.

import { basicSetup } from 'codemirror'
import { EditorView } from '@codemirror/view'
import {
    Compartment,
    EditorState,
    StateEffect,
    StateField,
    Text,
    type ChangeSpec,
    type TransactionSpec,
} from '@codemirror/state'

import { getChunks, unifiedMergeView } from '@codemirror/merge'

const unifiedDiffCompartment = new Compartment()

const setOriginalDoc = StateEffect.define<Text | null>()

const originalDocField = StateField.define<Text | null>({
    create() {
        return null
    },
    update(value, tr) {
        for (const effect of tr.effects)
            if (effect.is(setOriginalDoc)) return effect.value

        return value
    },
})

const reviewModeExtension = EditorState.transactionExtender.of(tr => {
    // Enter review mode: enable diff and set original document if not set
    if (tr.isUserEvent('review-changes')) {
        const originalDoc = tr.startState.field(originalDocField)
        const reviewDoc = originalDoc ?? tr.startState.doc

        const effects = [
            unifiedDiffCompartment.reconfigure([
                unifiedMergeView({
                    original: reviewDoc,
                    allowInlineDiffs: true,
                }),
            ]),
        ]

        if (!originalDoc) {
            effects.push(setOriginalDoc.of(reviewDoc))
        }

        return { effects }
    }

    // Exit review mode: disable diff when all changes are resolved
    if (tr.isUserEvent('accept') || tr.isUserEvent('revert')) {
        if (getChunks(tr.state)?.chunks.length === 0) {
            return {
                effects: [
                    setOriginalDoc.of(null),
                    unifiedDiffCompartment.reconfigure([]),
                ],
            }
        }
    }

    return null
})

export function createReviewModeExtension() {
    return [
        originalDocField,
        unifiedDiffCompartment.of([]),
        reviewModeExtension,
    ]
}

export function createReviewTransaction(
    changes: ChangeSpec[],
): TransactionSpec {
    return {
        changes,
        userEvent: 'review-changes',
    }
}

This can then be used in your editor setup as follows:

import { EditorState } from '@codemirror/state'
import { EditorView, basicSetup } from '@codemirror/view'
import { createReviewModeExtension, createReviewTransaction } from './review-extension'

const view = new EditorView({
    parent: document.body,
    doc: 'one\n2\nthree\n4',
    extensions: [basicSetup, createReviewModeExtension()],
})

...

view.dispatch(
    createReviewTransaction([
        { from:  4, to:  5, insert:  'two' },
        { from: 10, to: 11, insert: 'four' },
    ]),
)

Unified Merge View Demo

A random demo of the unified merge view with the original document on the left and the new document on the right. The “recreate” button will recreate the unified merge view with those documents, you can also try to edit the unified view itself and see how the changes are reflected in the diff view.

The “overlay trick”

The “overlay trick” is a technique used to create a syntax-highlighted code editor using an hidden native input element (like a textarea or a contenteditable div) for handling user input and a visible “overlay” element that renders the highlighted code. The overlay is positioned on top of the input element and is updated in real-time as the user types, giving the illusion of a syntax-highlighted editor while still leveraging the native text input capabilities of the browser. This hack works best when the rendered code in the overlay has the same layout as the text in the input element below to ensure that the cursor and selection are properly aligned.

This technique already breaks down very easily, rich text editors for example often feature text with various font sizes, images, or other non-text content. Even just parts in bold or italic can cause the font layout to be different between the input and the overlay, causing the cursor to be misaligned.