Buy now
4.1

Beyond the basic setup

So far the editor is really basic and it is using CodeMirror's basicSetup extension providing a bundle of commonly used CodeMirror extensions.

But for improved maintainability it is better to declare each feature with its individual extension. This helps control the JavaScript bundle by only including the necessary modules and add precise control over each extension configuration. As Marijn Haverbeke, CodeMirror's author, explains: “The library is designed around a flexible extension interface that allows features to be combined freely and makes it possible to implement even complex extensions in a robust way.”

This means that instead of just the basicSetup and EditorView imports:

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

You will get a bunch more import statements for each separate functionality:

import { EditorView, keymap, lineNumbers, highlightActiveLineGutter, highlightSpecialChars, drawSelection, dropCursor, rectangularSelection, crosshairCursor, highlightActiveLine } from "@codemirror/view"
import { indentOnInput, syntaxHighlighting, defaultHighlightStyle, bracketMatching, foldGutter, foldKeymap } from "@codemirror/language"
// …and quite a few more!

4.1.1 Updating NPM packages to newer versions

While this has clearly several advantages, the biggest downside, I find, is that you need to update many packages separately as well.

But just like Ruby's bundle update, there is Node's npm update that takes care of the mundane task of updating packages. It checks all dependencies in your package.json against the registry, updating packages to their latest version within the specified version range, where the caret (⁠^) selector allows updates to newer minor/patch versions but not major versions (e.g., ⁠^6.0.0 permits updates to 6.1.0 or 6.0.1 but not 7.0.0).

Other version range options in package.json are:

  • Tilde (~): updates to the latest patch version within the same minor version, e.g., ~1.2.3 allows 1.2.4 but not 1.3.0;
  • Exact version (1.2.3): locks to a specific version with no automatic updates;
  • Greater than (>1.2.3): accepts any version higher than specified;
  • Greater than or equal to (>=1.2.3): accepts the specified version or any higher version;
  • Less than (<1.2.3): accepts any version lower than specified;
  • Range (1.2.3 - 2.3.4): specifies an inclusive set of versions;
  • Hyphen ranges (1.2.3 - 2.3.4): Inclusive set of versions;
  • Star/latest (*): always uses the latest available version.

When you add no version when installing (yarn add codemirror), the latest version is used and saved with the ^ prefix by default.

You can see that Node's package.json offers quite a bit more flexible versioning, compared to a Gemfile where version constraints are typically specified with operators like ⁠~> (pessimistic versioning).

4.1.2 From one extension to many

The basicSetup has proven to be useful for the exploratory phase, but it's time to say goodbye.

Remove from app/javascript/controllers/editor_controller.js:

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

And replace it with:

import { standardSetup } from "./editor/setups"
import { EditorView } from "@codemirror/view"

Then remove basicSetup and add the, yet-to-be-created, standardSetup method into the extensions array:

export default class extends Controller {
  // …

  connect() {
    this.editor = new EditorView({
      doc: this.contentValue,
      parent: this.element,
      extensions: [
        standardSetup,

        EditorView.updateListener.of((update) => {
          if (update.docChanged) {
            this.statusTarget.textContent = "Saving…"

            this.debouncedUpdate()
          }
        })
      ]
    })
  }

  // …
}

Let's install a whole bunch of CodeMirror packages that contain all methods needed: yarn add @codemirror/view @codemirror/state @codemirror/language @codemirror/commands.

Now create a new file at app/javascript/controllers/editor/setups.js. Note that the file is in a folder named editor. I like to add all the editor-specific files in here. This is personal preference, but I find it easier if I ever need to copy the editor controller into a new project. Next add the following imports and the extensions like this:

import { keymap, lineNumbers, highlightActiveLineGutter, highlightSpecialChars, drawSelection, dropCursor, rectangularSelection, crosshairCursor, highlightActiveLine } from "@codemirror/view"
import { indentOnInput, syntaxHighlighting, defaultHighlightStyle, bracketMatching, foldGutter, foldKeymap } from "@codemirror/language"
import { history, historyKeymap, defaultKeymap, indentWithTab } from "@codemirror/commands"
import { highlightSelectionMatches } from "@codemirror/search"
import { EditorState } from "@codemirror/state"
import { lintKeymap } from "@codemirror/lint"

export const standardSetup = [
    lineNumbers(),
    highlightActiveLineGutter(),
    highlightSpecialChars(),
    history(),
    foldGutter(),
    drawSelection(),
    dropCursor(),
    EditorState.allowMultipleSelections.of(true),
    indentOnInput(),
    syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
    bracketMatching(),
    rectangularSelection(),
    crosshairCursor(),
    highlightActiveLine(),
    highlightSelectionMatches(),

    keymap.of([
        ...defaultKeymap,
        ...historyKeymap,
        ...foldKeymap,
        ...closeBracketsKeymap,
        ...lintKeymap,
        indentWithTab
    ])
]

This file is now importing the required methods from the various CodeMirror packages and then creates a named export (standardSetup).

Each of the imported methods adds specific functionality for the editor to work. You can now see it is adding essential functionality, like history (e.g. CMD+Z), you normally get for free in a form field. This is because of the contenteditable element being used. Going over each method—and why some except attributes—isn't in the scope of this book, but check out the Codemirror reference manual if you want to learn more.

The app/javascript/controllers/editor/setup.js file can hold various setups that can be loaded based on your needs and preferences. All this standardSetup essentially does, is recreating the the basicSetup from CodeMirror, but it is now yours and allows for easier modifications.

Before proceeding to the next step, let's review two new kinds of code syntax introduced above.

4.1.3 Method invocations

The first one is called method invocation (or method call). It allows for an expressiveness that gets close to Ruby. You can see it being used, for example, in the keymap array.

keymap.of([
        ...defaultKeymap,
        ...historyKeymap,
        // …
])

So how would you go about writing such code yourself? Two possible options:

const keymap = {
  of(array) {
    return array
  }
}

This is an object literal.

class Keymap {
  static of(array) {
    return array
  }
}

And this a class with static method. JavaScript classes have already been covered in an earlier chapter.

Which one to use depends on the complexity of the method and the related code-base. The object literal though can be placed right in your Stimulus controller file, outside of the class definition and you can use it how you want, let's give it a try with an example:

import { Controller } from "@hotwired/stimulus"

import { EditorView } from "@codemirror/view"
import { standardSetup } from "./editor/setups"

import { debounce } from "./helpers"

const log = {
  withMessage(value) {
    console.log("Message passed to `withMessage`:", value)

    return [] // …this is required because CodeMirror expects all extensions to return _something_
  }
}

export default class extends Controller {
  // …

    connect() {
        this.editor = new EditorView({
            doc: this.contentValue,
            parent: this.element,
            extensions: [
                standardSetup,

                log.withMessage("Hello from within `extensions`")

                // …
            ]
    })
  }

  // …
}

This simple example outputs Hello from within `extensions` to your console, but it shows how you can write readable JavaScript: log.withMessage("Hello from within `extensions`"). Not too shabby, right?

Have a play with it and then remove it, as it was just for demo purposes.

4.1.4 Flatten arrays using the spread-operator

There is one more new syntax introduced in the of method called the spread-operator:

keymap.of([
    ...defaultKeymap, // returns `[cmd1, cmd2]`
    ...historyKeymap, // returns `[cmd3, cmd4]`
    indentWithTab // returns `cmd5`
])

All methods, but indentWithTab, return an array. So without using the spread-operator, you would get an array with arrays: [[cmd1, cmd2], [cmd3, cmd4], cmd5]. But with the spread-operator: [cmd1, cmd2, cmd3, cmd4, cmd5].

The Ruby equivalent would look like this:

default_keymap = [cmd1, cmd2]
history_keymap = [cmd3, cmd4]
indent_with_tab = cmd5

# Without flatten:
[default_keymap, history_keymap, indent_with_tab] # => [[cmd1, cmd2], [cmd3, cmd4], cmd5]

# With flatten:
[default_keymap, history_keymap, indent_with_tab].flatten # => [cmd1, cmd2, cmd3, cmd4, cmd5]

With that all out of the way, it is time to add auto-completion to the editor.

← To contents

Book.find(1).options.limit(3)

const package = yourChoice

Solo

$ 39

What is included?

  • Full book (PDF/ePub)
  • Code-base included

Professional

$ 69

What is included?

Team (5 developers)

$ 169

What is included?

  • Full book (PDF/ePub)
  • Full code-base access
  • Share with 5 developers in your team
Finally, JavaScript explained while keeping my Ruby soul intact.