Tracking selection state in Elm

2020-01-17elm

Quick Summary

I’ve been exploring for the last month or so ways to make a rich text editor in Elm. For those not familiar with Elm, it’s a functional programming language with Haskell-like syntax that compiles to javascript. It promises no runtime exception, and is almost exclusively used for front end web development. I first ran into the language 6 years ago, and I come back to it now and then when building new projects to see what’s changed.

While the core language and library can be considered somewhat solid, the third party package support is a bit lacking. This is especially true if you compare it to more mainstream languages like Javascript, Go or Python. I ran into this a few years ago searching for a package similar to ProseMirror or DraftJS, and I found out there was absolutely no package in Elm that could do what I wanted.

However, the silver lining is that there are lots of opportunity to contribute back. So after a few years of waiting for a package to be created, I decided to see if I could make one of my own.

Requirements

So what are the requirements of a rich text editor? Well, among other things, it needs to support user selection, IME, spellcheck, autocorrect, as well as be able to display and edit things like styled text, lists, block quotes, tables, images, as well as all sort of other nested document elements. Basically, most things that you can do with the DOM with regards to block and inline elements, you need to be able to do with a rich text editor.

Up until now, there have been three major ways of building a rich text editor in the DOM. The first has been to use a text area, calculate the position of the text, and then hover elements over that text for effect. This is a somewhat limited in what types of elements you can display and prone to calculation error. Another way that’s done by libraries like Ace and CodeMirror using regular DOM elements to render the document, and emulating user actions through key and input events. IME and selection support is handled by a hidden text area. This approach works very well, especially for code editors, since most of what you need to do is modify text, and you don’t need native spellcheck support. Google Docs also appears to do things this way (specifically for the browser on a desktop). The third way, is to use contenteditable. Now most web developers know to stay away from contenteditable because of its lack of documentation and its quirks between browsers. However, it’s also the only native support browsers have for rich text editing. Using contenteditable also allows users to use native IME support without hidden textarea hacks, and spellcheck and autocomplete.

I decided to take the third approach because other libraries that I had used, namely DraftJS, took this approach. I had success building a custom editor with DraftJS when building a language exchange site, so I thought it wise to try to mirror their approach.

So how does DraftJS work? Well, it has its own document model for the editor, and any time it receives an input or composition event, it updates its model and rerenders the editor. It also keeps track of the selection state whenever it changes inside the editor. So if I want to do the same thing in Elm, the first thing I need to do is find a way to keep track of the selection state.

The first problem I ran into: there are no selection APIs exposed in Elm

Elm is technically still in alpha, and their approach to adding new functionality, to put it as nicely as possible, is very deliberate. Or to put it not nicely, it’s slow. Right now, there isn’t an API for mapping a selection in the browser to virtual DOM elements. However, in order to build a rich text editor with the same approach DraftJS and other took, keeping track of the selection state is a hard requirement. So I had to find a way to keep track of the selection state and map it to the document model that I had.

To do this, I again decided to take an approach similar to DraftJS. My document model looked like this:

type alias Document =
    { id : String
    , idCounter : Int
    , renderCount : Int
    , nodes : List DocumentNode
    , selection : Maybe Selection
    , currentStyles : CharacterMetadata
    , isComposing : Bool
    }

type alias DocumentNode =
    { id : String
    , characterMetadata : List CharacterMetadata
    , text : String
    , nodeType : String
    }

Each document node in the document has an id and some text. With this id, I create a mapping between the DOM representation and my model by setting the id when I render the node. In javascript, I subscribe to selectionchange events and update Elm with the new selection after translating the Selection API Object to the selection state my editor can understand. On the other side, whenever I want to set the selection, I have a webcomponent that I render the selection state from my model, and then update the selection when the selection attribute on that webcomponent changes. Why do I use a webcomponent? Well, it was the only way I could guarantee that the virtual dom had already rendered the most current version of the editor before updating the selection state. If I used ports, then there’s no such guarantee, and sometimes the selection state would be ahead of or behind the rendered document.

The Elm code for this implementation looks something like the following:

--- Listen to updates of selection state
port updateSelection : (E.Value -> msg) -> Sub msg

subscribe : Model -> Sub Msg
subscribe model =
    Sub.batch [ updateSelection SelectionEvent ]

-----------
-----------

--- Render the selectionstate web component
selectionAttributesIfPresent : Maybe Selection -> List (Html.Attribute Msg)
selectionAttributesIfPresent maybeSelection =
    case maybeSelection of
        Nothing ->
            []

        Just selection ->
            [ attribute "selection"
                (String.join ","
                    [ "focus-offset=" ++ String.fromInt selection.focusOffset
                    , "anchor-offset=" ++ String.fromInt selection.anchorOffset
                    , "anchor-node=" ++ selection.anchorNode
                    , "focus-node=" ++ selection.focusNode
                    , "is-collapsed="
                        ++ (if selection.isCollapsed then
                                "true"

                            else
                                "false"
                           )
                    , "range-count=" ++ String.fromInt selection.rangeCount
                    , "selection-type=" ++ selection.selectionType
                    , "render-count=" ++ String.fromInt d.renderCount
                    ]
                )
            ]

renderSelection : Maybe Selection -> Html Msg
renderSelection maybeSelection =
        node "selection-state" (selectionAttributesIfPresent maybeSelection) []

And on the javascript side:

let updateToCurrentSelection = () => {
  const selection = getSelection();
  const anchorNode = findDocumentNodeId(selection.anchorNode);
  const focusNode = findDocumentNodeId(selection.focusNode);

  if (!(anchorNode.id && focusNode.id)) {
    return;
  }

  let data = {
    "anchorOffset": selection.anchorOffset + anchorNode.offset,
    "focusOffset": selection.focusOffset + focusNode.offset,
    "isCollapsed": selection.isCollapsed,
    "rangeCount": selection.rangeCount,
    "type": selection.type,
    "anchorNode": anchorNode.id,
    "focusNode": focusNode.id,
  };
  app.ports.updateSelection.send(data);
};

document.addEventListener("selectionchange", updateToCurrentSelection);

/**
 * The SelectionState web component updates itself with the latest selection state, and also sets
 * the selection state whenever its attributes have been updated.  This is very useful for synchronizing
 * the selection state with what Elm thinks the selection state is, and seems to be the only way of making
 * sure the VirtualDOM has been updated already with the latest state before updating the selection state.
 */
class SelectionState extends HTMLElement {
  static get observedAttributes() {
    return ["selection"];
  }

  attributeChangedCallback(name, oldValue, selection) {
    let selectionObj = {};
    for (let pair of selection.split(",")) {
      let splitPair = pair.split("=");
      if (splitPair.length === 2) {
        selectionObj[splitPair[0]] = splitPair[1]
      }
    }
    const focusOffset = Number(selectionObj["focus-offset"]);
    const focusNode = selectionObj["focus-node"];
    const anchorOffset = Number(selectionObj["anchor-offset"]);
    const anchorNode = selectionObj["anchor-node"];

    if (focusNode && anchorNode) {
      updateSelectionToExpected({
        focusNode: focusNode,
        focusOffset: focusOffset,
        anchorOffset: anchorOffset,
        anchorNode: anchorNode
      });
    }
  }
}

customElements.define('selection-state', SelectionState);

The full code can be seen in the prototype RTE git repo.

Summary

So in short, I created a webcomponent to keep track of the state and used the selectionchange event to notify Elm of selection changes. I also created a mapping of my model to the DOM so I could translate the information from the Selection API with the nodes in my model.

In later articles, I’ll go over other parts of the first prototype and the solutions I came up with. I’ll also go over some of the shortcomings I found in this approach, and why I had to rethink how a rich text editor should be made in Elm.