Integrating CodeMirror with a Phoenix LiveView form
Published onThe 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:
- Code is entered into the CodeMirror editor.
- Clicking the submit button sends the content via a WebSocket to the Phoenix server.
- The server stores a record of the snippet and spawns a process to run the code.
- The server streams logs generated by the process back over the WebSocket.
- On revisiting the page the user sees their most recently submitted snippet.
There were two hurdles to overcome:
- The CodeMirror DOM structure does not use form elements, so how can we include the editor contents in the form data?
- 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.
- Text entered into the editor isn’t submitted as part of the form.
- 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:
- Pull out any existing content from a
textarea
and inject that into a CodeMirror editor. - 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.