Alex Pearwin

Integrating CodeMirror with a Phoenix LiveView form

The LiveView component of the Elixir web framework Phoenix has lots of JavaScript interoperability for client-side interactions, but it’s not always obvious how to leverage this for a specific library. We’ll go over how to do just that for the web-based CodeMirror editor, demonstrating how to integrate an editor into a LiveView form.

If you already have a Phoenix application up and running you might like skip the setup and to jump straight to the CodeMirror integration section. Either way, the full application is available on GitHub for study.

Context

I’ve been playing around with an application where users can submit code snippets and see the resulting logs in real time. The UI is just a code editor and ‘submit’ button in one half of the browser and a log pane in the other half.

The real-time interaction is powered by Phoenix’s LiveView module, with the flow looking something like this:

  1. Code is entered into the CodeMirror editor.
  2. Clicking the submit button sends the content via a WebSocket to the Phoenix server.
  3. The server stores a record of the snippet and spawns a process to run the code.
  4. The server streams logs generated by the process back over the WebSocket.
  5. On revisiting the page the user sees their most recently submitted snippet.

There were two hurdles to overcome:

  1. The CodeMirror DOM structure does not use form elements, so how can we include the editor contents in the form data?
  2. How can we inject the most recent code into the editor on revisiting the page?

Eventually I settled on using LiveView’s client-side phx-hook feature to solve both problems.

Setup

Before adding CodeMirror, we’ll create a new Phoenix application which has the functionality we’re after using vanilla HTML elements. Once the mechanics of wiring up the LiveView state are out of the way, we’ll to extend the form to use CodeMirror’s fancy editor for a nicer user experience.

Create a new Phoenix project1 to begin, backed by SQLite to avoid having to spin up a database server.

$ mix phx.new code_runner --database sqlite3

Next, generate the files which will back our data model: a single snippets table with content and language columns for storing the editor content and code language respectively.

$ mix phx.gen.schema Snippet snippets content:text language:string

Create the development database and run the migration we just made against it.

$ mix ecto.setup

Finally, create our code editing and execution interface. This will be encapsulated by a single LiveView module under lib/code_runner_web/live/snippet_execution_live.ex.

defmodule CodeRunnerWeb.Live.SnippetExecutionLive do
  use CodeRunnerWeb, :live_view
  import Ecto.Query, only: [from: 2]
  alias CodeRunner.Repo
  alias CodeRunner.Snippet

  @default_language "python"
  @log_interval 1_000

  def mount(_params, _session, socket) do
    socket =
      socket
      |> assign_initial_changeset()
      |> assign(:running, false)
      |> assign(:logs, [])

    {:ok, socket}
  end

  def render(assigns) do
    ~H"""
    <h2>Snippet</h2>
    <.form let={f} for={@changeset} phx-submit="create">
      <%= label f, :content do %>
        Content
        <%= textarea f, :content %>
        <%= error_tag f, :content %>
      <% end %>
      <%= label f, :language do %>
        Language
        <%= select f, :language, ["Elixir": "elixir", "Python": "python"], prompt: [key: "Language", disabled: true] %>
        <%= error_tag f, :language %>
      <% end %>
      <%= submit "Submit", disabled: @running %>
    </.form>
    <h2>Logs</h2>
    <%= if Enum.empty?(@logs) do %>
      <p>Waiting for snippet submission.</p>
    <% else %>
      <pre><code><%= for line <- Enum.reverse(@logs) do %><%= line %>
    <% end %></code></pre>
    <% end %>
    """
  end

  def handle_event("create", %{"snippet" => params}, socket) do
    case create_snippet(params) do
      {:ok, record} ->
        {:noreply,
         socket
         |> assign(changeset: record |> Snippet.changeset(%{}))
         |> put_flash(:info, "Snippet created. Running…")}

      {:error, %Ecto.Changeset{} = changeset} ->
        {:noreply,
         socket
         |> assign(changeset: changeset)
         |> clear_flash}
    end
  end

  def create_snippet(params) do
    %Snippet{}
    |> Snippet.changeset(params)
    |> Repo.insert()
  end

  defp assign_initial_changeset(socket) do
    # Assign a changeset to the most recent snippet, if one exists, or a new snippet.
    query =
      from(s in Snippet,
        order_by: [desc: s.inserted_at],
        limit: 1
      )

    changeset =
      case Repo.one(query) do
        nil -> %Snippet{language: @default_language}
        record -> %Snippet{content: record.content, language: record.language}
      end
      |> Snippet.changeset(%{})

    socket |> assign(changeset: changeset)
  end
end

This is fairly standard LiveView stuff, with the key points being:

  • On page load we fetch the latest snippet or creating a blank one if no snippets exist.
  • An Ecto changeset is made from the snippet and that’s used to populate the form values.
  • On form submission we attempt to save the code as a new snippet.

(I’ve omitted the process creation and log streaming logic here as its not relevant for the CodeMirror stuff, it just makes the application a bit more interesting! Check out the full module source for that.)

Update the router module at lib/code_runner_web/router.ex to replace the default index route at / with our LiveView.

defmodule CodeRunnerWeb.Router do
  use CodeRunnerWeb, :router
  # …
  scope "/", CodeRunnerWeb do
    pipe_through :browser

+    live "/", Live.SnippetExecutionLive
  end
  # …
end

Visit the page at http://localhost:4000 at try it out. It looks like this:

Integration

To extend our little app with a nicer editor we’ll first install CodeMirror’s codemirror wrapper package along with a language plugin for syntax highlighting.2

$ npm install codemirror @codemirror/lang-python --save --prefix assets

Some CodeMirror initialisation boilerplate goes in our app.js.

import { EditorView, basicSetup } from "codemirror"
import { EditorState, Compartment } from "@codemirror/state"
import { python } from "@codemirror/lang-python"

let language = new Compartment
let state = EditorState.create({
  extensions: [
    basicSetup,
    language.of(python()),
  ]
})
let view = new EditorView({
  state: state,
  parent: document.getElementById("editor")
})

And we then insert an #editor element into the view template.

  def render(assigns) do
    ~H"""
    <h2>Snippet</h2>
+    <div id="editor" phx-update="ignore"></div>
    <.form let={f} for={@changeset} phx-submit="create">
      <!-- omitted -->
    </.form>
    <!-- omitted -->
    """
  end

The phx-update="ignore" attribute tells LiveView to ignore updates made to this container. If we omit this, LiveView will notice CodeMirror making changes to the contents of the #editor element and see that these changes are out-of-sync with the template definition. It will then try to reconcile this difference by removing the contents of #editor! Using phx-update="ignore" tells LiveView not to worry about managing the contents of this component.

You should now be able to see a CodeMirror editor on the page. But we can now also see the two problems mentioned earlier.

  1. Text entered into the editor isn’t submitted as part of the form.
  2. Existing text loaded into the form on page load isn’t visible in the editor.

The trick is to synchronise the textarea with the editor. We can set up this synchronisation using client hooks via the phx-hook attribute.

We first annotate the textarea with the phx-hook attribute, telling LiveView the name of a JavaScript object we’ll create which contains some custom client-side code.

  def render(assigns) do
    ~H"""
    <h2>Snippet</h2>
    <div id="editor" phx-update="ignore"></div>
    <.form let={f} for={@changeset} phx-submit="create">
      <%= label f, :content do %>
        Content
-        <%= textarea f, :content %>
+        <%= textarea f, :content, phx_hook: "EditorForm" %>
        <%= error_tag f, :content %>
      <% end %>
      <!-- omitted -->
    </.form>
    <!-- omitted -->
    """
  end

The hook name is arbitrary; we’ve chosen EditorForm here.

All that’s left is to define the EditorForm hook in our app.js, passing this object as part of a ‘hooks’ definition we give to the LiveView’s socket constructor.

hooks = {
  EditorForm: {
    mounted() {
      let textarea = this.el

      // Initialise the editor with the content from the form's textarea
      let content = textarea.value
      let new_state = view.state.update({
        changes: { from: 0, to: view.state.doc.length, insert: content }
      })
      view.dispatch(new_state)

      // Synchronise the form's textarea with the editor on submit
      this.el.form.addEventListener("submit", (_event) => {
        textarea.value = view.state.doc.toString()
      })
    }
  }
}

let liveSocket = new LiveSocket(
  "/live",
  Socket,
  { params: { _csrf_token: csrfToken }, hooks: hooks }
)

We can now enter text in the editor, submit the form, and see the textarea contents are synchronised! It looks like this:

All that’s left to do is hide the textarea. You can accomplish this with a bit of CSS, e.g. display: none.

Summary

By annotating a form element with phx-hook we can ask LiveView to execute our custom JavaScript within the lifecycle of that component.

We used the mounted lifecycle event to:

  1. Pull out any existing content from a textarea and inject that into a CodeMirror editor.
  2. Register a form-submission event handler to pull out content from the CodeMirror editor and inject that back into the textarea ready for sending to the server.

This solves our two problems!

The same approach can be used to integrate other rich editors such as Ace or Monaco, as well as to interoperate with other client-side JavaScript libraries.

Footnotes

  1. See the Elixir and Phoenix installation guides for getting Mix and the phx.new task up and running.

  2. Dynamic switching of the editor’s highlighting configuration based on the form value is left as an exercise to the reader 🤠