Custom Kinos with Elixir and JavaScript
Mix.install([
{:kino, "~> 0.11.0"}
])
Introduction
Livebook allows developers to implement their own kinos. This allows developers to bring their own ideas to life and extend Livebook in unexpected ways.
There are two types of custom kinos: static, via Kino.JS
, and dynamic,
via Kino.JS.Live
. We will learn to implement both in this notebook.
HTML rendering with Kino.JS
The “hello world” of custom kinos is one that embeds and renders a given HTML string directly on the page.
We can implement it using Kino.JS
in less than 15 LOC:
defmodule KinoGuide.HTML do
use Kino.JS
def new(html) when is_binary(html) do
Kino.JS.new(__MODULE__, html)
end
asset "main.js" do
"""
export function init(ctx, html) {
ctx.root.innerHTML = html;
}
"""
end
end
Let’s break it down.
To define a custom kino we need to create a new module. In
this case we go with KinoGuide.HTML
.
We start by adding use Kino.JS
, which makes our module
asset-aware. In particular, it allows us to use the asset/2
macro to define arbitrary files directly in the module source.
All custom kinos require a main.js
file that defines a JavaScript
module and becomes the entrypoint on the client side. The
JavaScript module is expected to export the init(ctx, data)
function, where ctx
is a special object and data
is the
data passed from the Elixir side. In our example the init
function accesses the root element with ctx.root
and overrides
its content with the given HTML string.
Finally, we define the new(html)
function that builds our kino
with the given HTML. Underneath we call Kino.JS.new/2
specifying our module and the data available in the JavaScript
init
function later. Again, it’s a convention for each kino
module to define a new
function to provide uniform experience
for the end user.
Let’s give our Kino a try:
KinoGuide.HTML.new("""
Look!
I wrote this HTML from Kino!
""")
It works!
To learn more about other features provided by Kino.JS
,
including persisting the output of your custom kinos to .livemd
files,
check out the documentation.
Bidirectional live counter
Kinos with static data are useful, but they offer just a small peek
into what can be achieved with custom kinos. This time we will try out
something more exciting. Let’s use Kino.JS.Live
to build a counter that can be incremented both through Elixir calls
and client interactions. Not only that, our counter will automatically
synchronize across pages as multiple users access our notebook.
Our custom kino must use both Kino.JS
(for the assets)
and Kino.JS.Live
. Kino.JS.Live
works under the client-server
paradigm, where the client is the JavaScript code, and the server
is your Elixir code. Your Elixir code has to define a series of
callbacks (similar to a GenServer
and other Elixir behaviours). In particular, we need to define:
-
A
init/2
callback, that receives the argument and the “server”ctx
(in contrast to thectx
in JavaScript, which is the client context) -
A
handle_connect/1
callback, which is invoked whenever a new client connects. In here you must return the initial state of the new client -
A
handle_event/3
callback, responsible for handling any messages sent by the client -
A
handle_cast/2
callback, responsible for handling any Elixir messages sent viaKino.JS.Live.cast/2
Here is how the code will look like:
defmodule KinoGuide.Counter do
use Kino.JS
use Kino.JS.Live
def new(count) do
Kino.JS.Live.new(__MODULE__, count)
end
def bump(kino) do
Kino.JS.Live.cast(kino, :bump)
end
@impl true
def init(count, ctx) do
{:ok, assign(ctx, count: count)}
end
@impl true
def handle_connect(ctx) do
{:ok, ctx.assigns.count, ctx}
end
@impl true
def handle_cast(:bump, ctx) do
{:noreply, bump_count(ctx)}
end
@impl true
def handle_event("bump", _, ctx) do
{:noreply, bump_count(ctx)}
end
defp bump_count(ctx) do
ctx = update(ctx, :count, &(&1 + 1))
broadcast_event(ctx, "update", ctx.assigns.count)
ctx
end
asset "main.js" do
"""
export function init(ctx, count) {
ctx.root.innerHTML = `
Bump
`;
const countEl = document.getElementById("count");
const bumpEl = document.getElementById("bump");
countEl.innerHTML = count;
ctx.handleEvent("update", (count) => {
countEl.innerHTML = count;
});
bumpEl.addEventListener("click", (event) => {
ctx.pushEvent("bump");
});
}
"""
end
end
On the client side, we wrote some JavaScript that listens to
button clicks and dispatches them to the server via pushEvent
.
Let’s render our counter!
counter = KinoGuide.Counter.new(0)
As an experiment you can open another browser tab to verify that the counter is synchronized.
In addition to client events we can also use the Elixir API we defined for our counter.
KinoGuide.Counter.bump(counter)
In the next notebook we will get back to those concepts and extend Livebook with custom Smart cells!