Livebook で Web スライドを作る

## はじめに

ElixirConf EU 2023 での発表を無事終え、精神のキャパシティーが空いたので久しぶりに投稿します

https://www.elixirconf.eu/

今回は Livebook のデプロイ機能を使って、 Web スライドを作ります

役に立つ場面はないと思いますが、こんなこともできるよ、というネタです

実装したノートブックはこちら

https://github.com/RyoWakabayashi/elixir-learning/blob/main/livebooks/deploy/slide_show.livemd

## 実行環境

- Elixir 1.14.2
- Livebook 0.9.2

## セットアップ

セットアップセルに以下のコードを入力し、実行します

```elixir
Mix.install([
  {:kino, "~> 0.9.2"}
])
```

今回使うのは Kino だけです

https://github.com/livebook-dev/kino

## スライドショーモジュールの定義

以下のようにスライドショーモジュールを定義します

```elixir
defmodule SlideShow do
  use GenServer

  @slides [
    """
    # 大分県

    ## Oita, Japan

    ![大分県](https://1.bp.blogspot.com/-uEqcqdspoIE/WMJLJPr0C5I/AAAAAAABCd8/mXaaXFIAv8MD9qWwsQ1oR196-DIbWL2QwCLcB/s400/japan_character8_kyuusyuu5_ooita.png)
    """,
    """
    ## 日本一のおんせん県おおいた

    Oita, the best hot-spring resort in Japan

    - 源泉総数 Total number of springs: **5,093**
    - 湧出量 Hot spring output: **298,264** l / minutes 


    """,
    """
    <div style="transform: scale(1.3)">

    ## 絶品グルメ

    Excellent Gourmet

    </div>

    <div style="transform: scale(1.2)">

    ### とり天

    Chicken tempura

    ### 中津唐揚げ

    Nakatsu flied chiken

    ### 関あじ・関さば

    Seki horse mackerel and Seki mackerel

    </div>
    """,
    """
    ## 宇宙

    ## Space

    > 大分空港は、宇宙港（スペースポート)として活用することとなりました。
    >
    > 大分空港からは、航空機が人工衛星を搭載したロケットを翼に吊り下げて、空港から飛び立ち、空中でロケットを打ち上げます。

    > Oita Airport is to be utilized as a spaceport.
    > 
    > From Oita Airport, an aircraft will take off from the airport with a rocket carrying a satellite suspended from its wings and launch the rocket in mid-air.
    """,
    """
    # ぜひ来てください

    # Come and visit us!
    """
  ]

  @page_count Enum.count(@slides)

  def init(default_page) do
    {:ok, default_page}
  end

  def show_slide(page) do
    @slides
    |> Enum.at(page)
    |> then(fn content ->
      """
      <div class='flex flex-col justify-center items-center p-8' style='height: 600px'>

      """
      <> content <>
      """
      </div>
      """
    end)
    |> Kino.Markdown.new()
  end

  def handle_call(:current, _from, current_page) do
    {:reply, show_slide(current_page), current_page}
  end

  def handle_call(:next, _from, current_page) when current_page >= @page_count - 1 do
    {:reply, show_slide(0), 0}
  end

  def handle_call(:next, _from, current_page) do
    {:reply, show_slide(current_page + 1), current_page + 1}
  end

  def handle_call(:prev, _from, current_page) when current_page == 0 do
    {:reply, show_slide(@page_count - 1), @page_count - 1}
  end

  def handle_call(:prev, _from, current_page) do
    {:reply, show_slide(current_page - 1), current_page - 1}
  end
end
```

解説は後で行います

## スライドショーの開始

`GenServer.start_link` でスライドショーのプロセスを開始します

第1引数にスライドショーモジュール、第2引数に初期ページとして 0 を指定します

```elixir
{:ok, slide} = GenServer.start_link(SlideShow, 0)
```

## 画面の定義

スライドを表示するための枠を作ります

```elixir
frame = Kino.Frame.new()
```

![スクリーンショット 2023-04-24 13.01.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/369c9e3b-22c5-d9bd-2945-7459af81bb25.png)

この時点では空の枠で、 `Nothin here...` と表示されます

初期表示として現在のスライドを表示し、ページ移動のためのボタンを作ります

```elixir
Kino.Frame.render(frame, GenServer.call(slide, :current))

prev_button = Kino.Control.button("<")
next_button = Kino.Control.button(">")

[prev_button, next_button]
|> Kino.Layout.grid(columns: 8)
```

![スクリーンショット 2023-04-24 13.03.42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/27cafc0a-eea6-de99-68a4-ccedd889c521.png)

`Kino.Layout.grid(columns: 8)` によってボタンを横並びで左端に寄せています

## 動きの定義

prev_button をクリックしたら前ページ、next_button をクリックしたら次ページを表示するようにします

```elixir
[prev: prev_button, next: next_button]
|> Kino.Control.tagged_stream()
|> Kino.listen(fn
  {:prev, _} ->
    Kino.Frame.render(frame, GenServer.call(slide, :prev))
  {:next, _} ->
    Kino.Frame.render(frame, GenServer.call(slide, :next))
end)
```

これを実行した時点でボタンによるページ移動ができるようになります

![Apr-24-2023 13-08-52.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/ab2b3f49-17e7-a4c1-a9eb-5923e7c856b9.gif)

## デプロイ

左メニューからロケットアイコンをクリックし、デプロイメニューを表示します

![スクリーンショット 2023-04-24 13.10.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/ec52a69f-a6c4-c20f-bd6b-e9c35385e1ba.png)

Slug に slide （任意の文字列）を入れて、 `Deploy` をクリックします

下に `DEPLOYMENTS` が表示され、しばらくすると Status が Running になります

![スクリーンショット 2023-04-24 13.12.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/da7d2d75-9d23-af33-c7ae-9846283f1ab7.png)

`/app/slide` をクリックすると、以下のような画面が開き、 Web スライドとして動作します

![Apr-24-2023 13-15-49.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/d43e2545-480e-1bb9-41e6-d378693d0e5e.gif)

## 解説

以降、 `SlideShow` モジュールの解説をします

### 状態管理

`GenServer` を使うことでスライドの現在ページを保持します

### スライドの内容定義

`SlideShow` モジュールの `@slides` に文字列のリストでスライドの内容を定義しています

ここを書き換えることで任意のスライドショーが作成できます

内容は Markdown で記述しています

従って、見出しは `# 見出し` や `## 小見出し` のように記述します

```markdown
    # 大分県
```

また、 `![<alt>](<画像URL>)` で画像、 `-` で列挙、 `>` で引用が表現できます

```markdown
    ![大分県](https://1.bp.blogspot.com/-uEqcqdspoIE/WMJLJPr0C5I/AAAAAAABCd8/mXaaXFIAv8MD9qWwsQ1oR196-DIbWL2QwCLcB/s400/japan_character8_kyuusyuu5_ooita.png)
...
    - 源泉総数 Total number of springs: **5,093**
    - 湧出量 Hot spring output: **298,264** l / minutes 
...
    > 大分空港は、宇宙港（スペースポート)として活用することとなりました。
    >
    > 大分空港からは、航空機が人工衛星を搭載したロケットを翼に吊り下げて、空港から飛び立ち、空中でロケットを打ち上げます。
```

更に HTML を埋め込むことも可能なので、 `<div style="transform: scale(1.3)">` と `</div>` で囲むことで、対象範囲内を拡大表示する、といったこともできます

```markdown
    <div style="transform: scale(1.3)">

    ## 絶品グルメ

    Excellent Gourmet

    </div>
```

`show_slide` 関数で指定ページのスライドを Markdown として解釈し、画面に表示します

```elixir
  def show_slide(page) do
    @slides
    |> Enum.at(page)
    |> then(fn content ->
      """
      <div class='flex flex-col justify-center items-center p-8' style='height: 600px'>

      """
      <> content <>
      """
      </div>
      """
    end)
    |> Kino.Markdown.new()
  end
```

Livebook では Tailwind CSS を使っているため、ある程度の（Livebook で使っている）　　Tailwind CSS のクラスが使えます

`class='flex flex-col justify-center items-center p-8'` により、中央寄せと余白を作っています

また、 `style='height: 600px'` により、スライドの高さを固定しています

(`h-[600px]` のように固定値を指定する Tailwind CSS のクラスは効かないので style 属性で指定しています)

### ページ移動

以下のコードで現在ページを表示しています

`handle_call` の戻り値タプルの2番目が実際に結果として返る値、3番目が保持する状態です

`:current` では状態が変化しないので、 current_page をそのまま返します

```elixir
  def handle_call(:current, _from, current_page) do
    {:reply, show_slide(current_page), current_page}
  end
```

以下のコードで次ページを表示します

`when current_page >= @page_count - 1` によって、最終ページの場合は先頭に戻るようにしています

```elixir
  def handle_call(:next, _from, current_page) when current_page >= @page_count - 1 do
    {:reply, show_slide(0), 0}
  end

  def handle_call(:next, _from, current_page) do
    {:reply, show_slide(current_page + 1), current_page + 1}
  end
```

以下のコードで前ページを表示します

`when current_page == 0` によって先頭ページの場合は最終ページに戻るようにします

```elixir
  def handle_call(:prev, _from, current_page) when current_page == 0 do
    {:reply, show_slide(@page_count - 1), @page_count - 1}
  end

  def handle_call(:prev, _from, current_page) do
    {:reply, show_slide(current_page - 1), current_page - 1}
  end
```