livebook/lib/livebook/notebook/learn/kino/custom_kinos.livemd
2023-07-10 22:18:47 +02:00

200 lines
5.4 KiB
Markdown

# Custom Kinos with Elixir and JavaScript
```elixir
Mix.install([
{:kino, "~> 0.10.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`](https://hexdocs.pm/kino/Kino.JS.html)
in less than 15 LOC:
```elixir
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:
```elixir
KinoGuide.HTML.new("""
<h3>Look!</h3>
<p>I wrote this HTML from <strong>Kino</strong>!</p>
""")
```
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](https://hexdocs.pm/kino/Kino.JS.html).
## 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`](https://hexdocs.pm/kino/Kino.JS.Live.html)
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`](https://hexdocs.pm/elixir/GenServer.html)
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 the `ctx` 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 via `Kino.JS.Live.cast/2`
Here is how the code will look like:
```elixir
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 = `
<div id="count"></div>
<button id="bump">Bump</button>
`;
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!
```elixir
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.
```elixir
KinoGuide.Counter.bump(counter)
```
In the next notebook we will get back to those concepts and
[extend Livebook with custom Smart cells](/learn/notebooks/smart-cells)!