Powered by AppSignal & Oban Pro

Kino Custom

livebooks/kino/kino_custom.livemd

Kino Custom

Mix.install([
  {:kino, "~> 0.15"}
])

Kino.JS

defmodule KinoDocs.HTML do
  use Kino.JS

  def new(html) do
    Kino.JS.new(__MODULE__, html)
  end

  asset "main.js" do
    """
    export function init(ctx, html) {
      ctx.importCSS("https://fonts.googleapis.com/css?family=Sofia")
      ctx.importCSS("main.css")

      ctx.root.innerHTML = html;
    }
    """
  end

  asset "main.css" do
    """
    body {
      font-family: "Sofia", sans-serif;
    }
    """
  end
end
KinoDocs.HTML.new("""
<div>
  Hello
  <ul>
    <li>World</li>
    <li>Elixir</li>
    <li>Livebook</li>
    <li>Kino</li>
  </ul>
</div>
""")
defmodule KinoCustom.List do
  use Kino.JS

  def new(element) do
    element
    |> generate_html()
    |> then(&Kino.JS.new(__MODULE__, "<div>#{&1}</div>"))
  end

  defp generate_html(element) when is_list(element) do
    if Keyword.keyword?(element) do
      element
      |> Enum.into(%{})
      |> generate_html()
    else
      element
      |> Enum.map(&"#{generate_html(&1)}")
      |> then(&"<ul>#{&1}</ul>")
    end
  end

  defp generate_html(element) when is_tuple(element) do
    element
    |> Tuple.to_list()
    |> generate_html()
  end

  defp generate_html(element) when is_map(element) do
    element
    |> Enum.map(fn {key, value} ->
      cond do
        is_binary(value) or is_atom(value) or is_number(value) ->
          "#{value}"

        true ->
          generate_html(value)
      end
      |> then(&"#{key}: #{&1}")
      |> generate_html()
    end)
  end

  defp generate_html(element)
       when is_binary(element) or is_atom(element) or is_number(element) do
    "<li>#{element}</li>"
  end

  asset "main.js" do
    """
    export function init(ctx, html) {
      ctx.importCSS("main.css")
      ctx.root.innerHTML = html;
    }
    """
  end

  asset "main.css" do
    """
    li {
      color: rgb(79, 53, 96);
      width: fit-content;
    }

    li::marker {
      content: "- ";
    }
    """
  end
end
KinoCustom.List.new(["a", "b", "c"])
KinoCustom.List.new([
  "a",
  %{"b" => ["b1", "b2"]},
  %{c: {%{c1: "A"}, "c2", [1, 2]}},
  [d1: 10, d2: [21, 22]]
])
defmodule KinoDocs.Mermaid do
  use Kino.JS

  def new(graph) do
    Kino.JS.new(__MODULE__, graph)
  end

  asset "main.js" do
    """
    import "https://cdn.jsdelivr.net/npm/mermaid@9.1.3/dist/mermaid.min.js";

    mermaid.initialize({ startOnLoad: false });

    export function init(ctx, graph) {
      mermaid.render("graph1", graph, (svgSource, bindListeners) => {
        ctx.root.innerHTML = svgSource;
        bindListeners && bindListeners(ctx.root);
      });
    }
    """
  end
end
KinoDocs.Mermaid.new("""
graph TD;
  A-->B;
  A-->C;
  B-->D;
  C-->D;
""")
defmodule KinoCustom.Three do
  use Kino.JS

  def new(color) do
    Kino.JS.new(__MODULE__, color)
  end

  asset "main.js" do
    """
    import "https://unpkg.com/three@0.142.0/build/three.min.js";

    export function init(ctx, color) {
      const canvas = document.createElement("canvas");
      ctx.root.appendChild(canvas);

      const renderer = new THREE.WebGLRenderer({canvas: canvas});
      const width = 320;
      const height = 320;
      renderer.setSize(width, height);

      const scene = new THREE.Scene();

      const camera = new THREE.PerspectiveCamera(45, width / height, 1, 1000);

      camera.position.set(0, 0, 500);

      const size = 80;
      const geometry = new THREE.BoxGeometry(size, size, size);
      const material = new THREE.MeshStandardMaterial({color: color});

      const box = new THREE.Mesh(geometry, material);
      scene.add(box);

      const light = new THREE.DirectionalLight(0xffffff);
      light.intensity = 2;
      light.position.set(1, 1, 1);
      scene.add(light);

      light.position.set(1, 1, 1);

      renderer.render(scene, camera);

      tick();

      function tick() {
        requestAnimationFrame(tick);

        box.rotation.x += 0.05;
        box.rotation.y -= 0.05;

        renderer.render(scene, camera);
      }
    }
    """
  end
end
KinoCustom.Three.new("green")

Kino.JS.Live

defmodule KinoDocs.LiveHTML do
  use Kino.JS
  use Kino.JS.Live

  def new(html) do
    Kino.JS.Live.new(__MODULE__, html)
  end

  def replace(kino, html) do
    Kino.JS.Live.cast(kino, {:replace, html})
  end

  @impl true
  def init(html, ctx) do
    {:ok, assign(ctx, html: html)}
  end

  @impl true
  def handle_connect(ctx) do
    {:ok, ctx.assigns.html, ctx}
  end

  @impl true
  def handle_cast({:replace, html}, ctx) do
    broadcast_event(ctx, "replace", html)
    {:noreply, assign(ctx, html: html)}
  end

  asset "main.js" do
    """
    export function init(ctx, html) {
      ctx.root.innerHTML = html;

      ctx.handleEvent("replace", (html) => {
        ctx.root.innerHTML = html;
      });
    }
    """
  end
end
list =
  KinoDocs.LiveHTML.new("""
  <h1>Hello</h1>
  """)
KinoDocs.LiveHTML.replace(list, """
<h2 style="color: red">World</h2>
""")
defmodule KinoCustom.Bar do
  use Kino.JS
  use Kino.JS.Live

  def new(width) do
    Kino.JS.Live.new(__MODULE__, width)
  end

  def update(kino, width) do
    Kino.JS.Live.cast(kino, {:update, width})
  end

  @impl true
  def init(html, ctx) do
    {:ok, assign(ctx, html: html)}
  end

  @impl true
  def handle_connect(ctx) do
    {:ok, ctx.assigns.html, ctx}
  end

  @impl true
  def handle_cast({:update, width}, ctx) do
    broadcast_event(ctx, "update", width)
    {:noreply, assign(ctx, width: width)}
  end

  asset "main.js" do
    """
    export function init(ctx, width) {
      const bar = document.createElement("div");
      bar.className = "bar";
      bar.style.width = width;
      bar.style.height = "40px";
      bar.style.backgroundColor = "red";

      ctx.root.appendChild(bar);

      ctx.handleEvent("update", (width) => {
        bar.style.width = width
      });
    }
    """
  end
end
bar = KinoCustom.Bar.new("50%")
Stream.interval(50)
|> Stream.take(100)
|> Kino.animate(fn width ->
  KinoCustom.Bar.update(bar, "#{width}%")
end)

Kino.SmartCell

defmodule Kino.SmartCell.Plain do
  use Kino.JS
  use Kino.JS.Live
  use Kino.SmartCell, name: "Plain code editor"

  @impl true
  def init(attrs, ctx) do
    source = attrs["source"] || ""
    {:ok, assign(ctx, source: source)}
  end

  @impl true
  def handle_connect(ctx) do
    {:ok, %{source: ctx.assigns.source}, ctx}
  end

  @impl true
  def handle_event("update", %{"source" => source}, ctx) do
    broadcast_event(ctx, "update", %{"source" => source})
    {:noreply, assign(ctx, source: source)}
  end

  @impl true
  def to_attrs(ctx) do
    %{"source" => ctx.assigns.source}
  end

  @impl true
  def to_source(attrs) do
    attrs["source"]
  end

  asset "main.js" do
    """
    export function init(ctx, payload) {
      ctx.importCSS("main.css");

      ctx.root.innerHTML = `
        <textarea id="source"></textarea>
      `;

      const textarea = ctx.root.querySelector("#source");
      textarea.value = payload.source;

      textarea.addEventListener("change", (event) => {
        ctx.pushEvent("update", { source: event.target.value });
      });

      ctx.handleEvent("update", ({ source }) => {
        textarea.value = source;
      });

      ctx.handleSync(() => {
        // Synchronously invokes change listeners
        document.activeElement &&
          document.activeElement.dispatchEvent(new Event("change"));
      });
    }
    """
  end

  asset "main.css" do
    """
    #source {
      box-sizing: border-box;
      width: 100%;
      min-height: 100px;
    }
    """
  end
end
Kino.SmartCell.register(Kino.SmartCell.Plain)
target = "World"

"Hello, #{target}"
defmodule KinoCustom.Color do
  use Kino.JS
  use Kino.JS.Live

  def new(color) do
    Kino.JS.Live.new(__MODULE__, color)
  end

  @impl true
  def init(html, ctx) do
    {:ok, assign(ctx, html: html)}
  end

  @impl true
  def handle_connect(ctx) do
    {:ok, ctx.assigns.html, ctx}
  end

  @impl true
  def handle_cast({:update, color}, ctx) do
    broadcast_event(ctx, "update", color)
    {:noreply, assign(ctx, color: color)}
  end

  asset "main.js" do
    """
    export function init(ctx, color) {
      const bar = document.createElement("div");
      bar.style.width = "100%";
      bar.style.height = "40px";
      bar.style.backgroundColor = color;

      ctx.root.appendChild(bar);
    }
    """
  end
end
KinoCustom.Color.new("red")
defmodule KinoCustom.Palette do
  use Kino.JS
  use Kino.JS.Live
  use Kino.SmartCell, name: "Palette"

  @impl true
  def init(attrs, ctx) do
    color = attrs["color"] || "white"
    {:ok, assign(ctx, color: color)}
  end

  @impl true
  def handle_connect(ctx) do
    {:ok, %{color: ctx.assigns.color}, ctx}
  end

  @impl true
  def handle_event("update", %{"color" => color}, ctx) do
    broadcast_event(ctx, "update", %{"color" => color})
    {:noreply, assign(ctx, color: color)}
  end

  @impl true
  def to_attrs(ctx) do
    %{"color" => ctx.assigns.color}
  end

  @impl true
  def to_source(attrs) do
    quote do
      KinoCustom.Color.new(unquote(attrs["color"]))
    end
    |> Kino.SmartCell.quoted_to_string()
  end

  asset "main.js" do
    """
    export function init(ctx, payload) {
      ctx.importCSS("main.css");

      const input = document.createElement("input");
      input.type = "text"
      input.value = payload.color;

      const output = document.createElement("output");
      output.style.color = payload.color;

      const rgbContainer = document.createElement("div");

      const rLabel = document.createElement("span");
      rLabel.innerText = "R: ";
      rgbContainer.appendChild(rLabel);
      const rValue = document.createElement("span");
      rValue.className = "color-value";
      rValue.innerText = "255";
      rgbContainer.appendChild(rValue);

      const gLabel = document.createElement("span");
      gLabel.innerText = "G: ";
      rgbContainer.appendChild(gLabel);
      const gValue = document.createElement("span");
      gValue.className = "color-value";
      gValue.innerText = "255";
      rgbContainer.appendChild(gValue);

      const bLabel = document.createElement("span");
      bLabel.innerText = "B: ";
      rgbContainer.appendChild(bLabel);
      const bValue = document.createElement("span");
      bValue.className = "color-value";
      bValue.innerText = "255";
      rgbContainer.appendChild(bValue);

      ctx.root.appendChild(input);
      ctx.root.appendChild(output);
      ctx.root.appendChild(rgbContainer);

      input.addEventListener("change", (event) => {
        ctx.pushEvent("update", { color: event.target.value });
      });

      ctx.handleEvent("update", ({ color }) => {
        input.value = color;
        output.style.color = color;

        const rgb =
          window
            .getComputedStyle(output)
            .color
            .replace("rgb(", "")
            .replace(")", "")
            .split(",")
            .map(ch => ch.trim());
        
        rValue.innerText = rgb[0];
        gValue.innerText = rgb[1];
        bValue.innerText = rgb[2];

        console.log(rgb);
      });

      ctx.handleSync(() => {
        document.activeElement &&
          document.activeElement.dispatchEvent(new Event("change"));
      });
    }
    """
  end

  asset "main.css" do
    """
    .color-value {
      margin-right: 16px;
    }
    """
  end
end
Kino.SmartCell.register(KinoCustom.Palette)
KinoCustom.Color.new("white")