# 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("""
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](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 = ` `; 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)!