# Run as: iex --dot-iex path/to/notebook.exs
# Title: Custom Kinos with Elixir and JavaScript
Mix.install([
{:kino, "~> 0.18.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:
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("""
<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:
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!
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](/learn/notebooks/smart-cells)!