mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-10-08 20:46:16 +08:00
200 lines
5.4 KiB
Markdown
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)!
|