Svelte で PWA (台本ビューア) <第5回>

[PWA で作った台本ビューア](https://github.com/satamame/pscv/tree/0.0.2) を [Svelte](https://svelte.jp) で作り直すことにしたので、やったことをメモしていきます。

- 前回 : [<第4回> Back ボタン対応](https://qiita.com/satamame/items/ff499e66fe55aa0fd551)
- 次回 : [<第6回> 更新の動作の改善](https://qiita.com/satamame/items/74bff0c6e653ffe55f88)
- [シリーズ記事一覧](https://qiita.com/satamame/items/3e0683a1fc6ecd6b66ee#%E3%82%B7%E3%83%AA%E3%83%BC%E3%82%BA%E8%A8%98%E4%BA%8B%E4%B8%80%E8%A6%A7)

# <第5回> データを読込む

**今回の概要**

- 本アプリで扱う台本データのクラスを作ります。
- 台本データ (JSON ファイル) のサンプルをアプリに同梱します。
- サンプルデータを読込む UI と関数を作ります。
- 読込んだサンプルデータを画面で確認できるようにします。

今回使うソースコードは [GitHub のこちらのコミット](https://github.com/satamame/pscv/tree/qiita.05) にあります。

# 台本データ

このアプリで扱う台本データは JSON です。
具体的なフォーマットは [playscript](https://github.com/satamame/playscript) という Python パッケージに準じます。

```json:台本データの例
{
  "class": "PSc",
  "title": "マダムと謎のいいがかり",
  "author": "沙汰青豆",
  "chars": [
    "生名",
    "宝来",
    (中略)
  ],
  "lines": [
    {
      "class": "PScLine",
      "type": "TITLE",
      "text": "マダムと謎のいいがかり"
    },
    {
      "class": "PScLine",
      "type": "AUTHOR",
      "text": "沙汰青豆"
    },
    (中略)
   {
      "class": "PScLine",
      "type": "ENDMARK",
      "text": "「マダムと謎のいいがかり」おわり"
    }
  ]
}
```

## 台本データのクラス

台本データのクラスを `PSc` という名前にして、lib フォルダ内に作りました (lib フォルダは .ts ファイルをまとめてあるフォルダです)。
まず、playscript パッケージの `PSc` クラスと同じ構造を作ります ([→ソース全文](https://github.com/satamame/pscv/blob/qiita.05/src/lib/psc.ts))。

```typescript:src/lib/psc.ts (抜粋)
/** 行の種類 */
export const PSC_LINE_TYPE = {
  TITLE: 0,    // 題名
  AUTHOR: 1,   // 著者名
  /* 中略 */
  ENDMARK: 9,  // エンドマーク
  .....
} as const

export type PScLineType = typeof PSC_LINE_TYPE[keyof typeof PSC_LINE_TYPE]

/** 台本行オブジェクトの定義 */
export class PScLine {
  constructor (
    public type: PScLineType,
    public name: string,
    public text: string,
  ) {}
}

/** 台本オブジェクトの定義 */
export class PSc {
  constructor (
    public title: string,
    public author: string,
    public chars: string[],
    public lines: PScLine[],
  ) {}
}
```

`PSc` クラスの `title`, `author`, `chars` はメタデータです。台本として表示する情報は `lines` に入っています。
`lines` の要素になる `PScLine` は台本内の「行」です。「セリフ行」「ト書き行」といった種類があり、それを定義しているのが `PSC_LINE_TYPE` です。

## 台本データを同梱する

同梱するデータの置き場所として、アプリのルートディレクトリに sample というフォルダを作ることにしました。
public フォルダの中に入れておけばビルド時にアプリのルートディレクトリにコピーされますので、そこに作ります。

```
pscv
 └─public
    └─sample  (このフォルダを追加)
       ├─help.json
       └─madam.json
```

データを「同梱する」という言い方をしていますが、実態としてはサーバに置くのと同じです。
ただし毎回サーバに取りに行くのでなく、アプリのインストール時にキャッシュされるようにしたいので、vite-plugin-pwa のオプションの `includeAssets` に 'sample/*.json' を追加します。

```typescript:vite.config.ts (抜粋)
const pwaOptions = {
  /* 中略 */
  includeAssets: ['ui_icon/*.svg', 'sample/*.json'], // ここに追加
  /* 中略 */
}
export default defineConfig({
  base: '/pscv/',
  plugins: [
    svelte(),
    VitePWA(pwaOptions), // ここで vite-plugin-pwa を設定している
  ],
})
```

# データを読込む UI

サンプルを読込むトリガーとなる UI は、メニュー (`MainMenu` コンポーネント) からパネルを開いて表示することにしました。
メニュー項目に「台本データ」を追加し、それを押してパネル (`Data` コンポーネント) を開くようにしました。

## メニュー項目

メニュー項目からパネルを開く仕組みは「バージョン情報」で `About` コンポーネントを開くようにした時と同じです ([→参考](https://qiita.com/satamame/items/36c6761d363ca3894824#about-%E3%82%92%E9%96%8B%E3%81%8F%E3%81%BE%E3%81%A7%E3%81%AE%E9%83%A8%E5%88%86))。Android の Back ボタンにも対応します ([→参考](https://qiita.com/satamame/items/ff499e66fe55aa0fd551#aboutsvelte-%E3%82%92%E9%96%8B%E3%81%8F%E5%A0%B4%E5%90%88))。
`MainMenu` コンポーネントのソース全文は [こちら](https://github.com/satamame/pscv/blob/qiita.05/src/components/MainMenu.svelte) にあります。

```html:src/components/MainMenu.svelte (抜粋)
<script lang="ts">
  /* 中略 */
  function openData() { // openAbout() と同じように関数を作る
    if (gone) { return }
    gone = true
    setTimeout(() => { dispatch('close') }, 200)
    dispatch('openData')
  }

  function openAbout() { // 第3回～第4回で作った関数
    if (gone) { return }
    gone = true
    setTimeout(() => { dispatch('close') }, 200)
    dispatch('openAbout')
  }
</script>

.....

<div class="panel" class:gone>
  <h1>メニュー</h1>
  <button class="icon-button close-button">
    <img alt="閉じる" src="{closeIcon}" on:click="{close}" />
  </button>

  <ul>
    <li>表示</li>
    <li on:click="{openData}"><img alt="データ" src="{booksIcon}" /><span>台本データ</span></li>
    <li on:click="{openAbout}"><img alt="情報" src="{infoIcon}" /><span>バージョン情報</span></li>
  </ul>
</div>
.....
```

![05_menu.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339253/1e4a973e-d47d-9f12-c3fc-2ce012d2085c.png)

クリックした時に親コンポーネントの `openData()` を呼ぶようにしたので、App.svelte を以下のようにします。
`Data` コンポーネントはまだありませんが、あるものとして関連する処理を追加してあります ([→ソース全文](https://github.com/satamame/pscv/blob/qiita.05/src/App.svelte))。

```html:src/App.svelte (抜粋)
<script>
  import Data from './components/Data.svelte' // 追加
  /* 中略 */
  let data                   // 追加
  let dataIsOpen = false     // 追加
  let psc: PSc | undefined   // 追加
  /* 中略 */

  // Initialize Android's back button handler
  if (isAndroid) {
    initBackHandler((): BackFunc => {
      // This callback returns functions to be invoked by back button
      return {
        toc: toc?.close,
        menu: menu?.close,
        data: data?.close,   // 追加
        about: about?.close,
      }
    })
  }
</script>

.....
{#if menuIsOpen}
  <MainMenu
    bind:this="{menu}"
    on:close="{() => { menuIsOpen = false }}"
    on:openData="{() => { dataIsOpen = true }}"
    on:openAbout="{() => { aboutIsOpen = true }}"
  />
{/if}

{#if dataIsOpen}
  <Data
    bind:this="{data}"
    on:close="{() => { dataIsOpen = false }}"
    on:showPSc="{(e) => { psc = e.detail.psc }}"
  />
{/if}
.....
```

## 「台本データ」パネル

開けたり閉めたりする処理は「バージョン情報」パネルと同じなので、今回のテーマに関わる箇所だけ抜粋します ([→ソース全文](https://github.com/satamame/pscv/blob/qiita.05/src/components/Data.svelte))。
Android の Back ボタンのハンドラについても割愛します ([→GitHub 上のソース](https://github.com/satamame/pscv/blob/qiita.05/src/lib/back.ts#L23))

```html:src/components/Data.svelte (抜粋)
<script lang="ts">
  import { SAMPLES } from '../lib/const' // 今から定義する定数
  import { PSc } from '../lib/psc'
  /* 中略 */

  let sampleSelect = SAMPLES[0]
  /* 中略 */

  async function showSample() {
    try {
      const psc = await PSc.fromUrl(sampleSelect.path) // 関数は今から作る
      if (psc) {
        dispatch('showPSc', { psc }) // イベントを発行して親にデータを渡す
        close()
      } else {
        throw new Error('読み込めませんでした。')
      }
    } catch (error) {
      alert(error.message)
    }
  }
</script>

<div class="panel" class:gone>
  <h1>台本データ</h1>
  <button class="icon-button close-button">
    <img alt="閉じる" src="{closeIcon}" on:click="{close}" />
  </button>

  <div class="container">
    <h2>サンプル</h2>
    <select bind:value="{sampleSelect}">
      {#each SAMPLES as sample }
        <option value="{sample}">{sample.title}</option>
      {/each}
    </select>
    <button on:click="{showSample}">表示</button>
  </div>
</div>
```

やっていることは、サンプルファイルを選択するプルダウンとそれを読込むボタンの表示、そしてボタンが押された時の処理です。

軽くポイントを説明します。
まず `SAMPLES` という定数の定義を見ておきます。他の定数と同じく const.ts の中で定義しています。

```typescript:src/lib/const.ts (抜粋)
export const SAMPLES = [
  { title: 'マダムと謎のいいがかり', path: 'sample/madam.json' },
  { title: 'ヘルプ', path: 'sample/help.json' }
] as const
```

これをプルダウンの中で `#each` で回して、各要素の `title` プロパティを表示しています。

![05_data_pulldown.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339253/e4abfa89-2c94-13a9-d378-4e11cfb1ec7c.png)

選択した値が `sampleSelect` という変数に入るように (バインド) してありますが、この「値」は `SAMPLES` の要素、つまりオブジェクトになります。ここが Svelte の面白いところです ([→参考](https://svelte.jp/tutorial/select-bindings))。

ボタンのハンドラである `showSample()` では、今から作る関数で台本データを取得して、それを `showPSc` イベントに乗せて親コンポーネント (App.svelte) に 渡しています。
それを受け取った親コンポーネントが何をするかは後で見ることにして、台本データを取得する関数 `PSc.fromUrl()` について見てみます。

# データを読込む関数

データを読込む関数は `PSc` オブジェクトを返すので、`PSc` クラスのクラスメソッドにしました。

```typescript:src/lib/psc.ts (抜粋)
export class PSc {
  /* 中略 */

  /** URL からデータを取得して PSc オブジェクトを返す */
  static async fromUrl(url: string): Promise<PSc> {
    const res = await fetch(url)
    if (res.ok) {
      const data = await res.json()
      // fetch で取得したデータは { psc: PSc } または PSc の形を想定する
      const pscData: PSc = data.psc ? data.psc : data
      try {
        // インスタンス化して返す
        const psc = new PSc(
          pscData.title, pscData.author, pscData.chars, pscData.lines
        )
        return psc
      } catch (error) {
        throw new Error('読み込めませんでした。')
      }
    } else {
      throw new Error('読み込めませんでした。')
    }
  }
}
```

引数の `url` には `SAMPLES` の要素の `path` プロパティの値、たとえば 'sample/madam.json' という値が入ってきます。
これはアプリのルートからの相対パスになりますので、アプリを xxx.com/pscv/ で公開しているなら xxx.com/pscv/sample/madam.json を取ろうとして、キャッシュされているのでキャッシュから取得する、ということになります。

もっとも、この関数はサンプルデータ専用ではないので、引数に 'https://github.com/satamame/pscv/blob/0.0.2/pscv/psc/sample.json' 等という値を入れて呼び出せば、そこから台本データを取ってきます。

この行についても説明します。

```typescript:src/lib/psc.ts (抜粋)
      // fetch で取得したデータは { psc: PSc } または PSc の形を想定する
      let pscData: PSc = data.psc ? data.psc : data
```

`url` から取得したデータは、台本データそのままの形をしていない場合も想定しています。
たとえば何等かの API、具体的には [pscapi](https://github.com/satamame/pscapi) から返ってきたレスポンスが台本データを含む場合、それはレスポンスの `data` フィールドにそのまま入っているのではなく、`data` の中の `psc` というフィールドに入っているかも知れません。
これは API 側の都合ですが、それを考慮して `psc` フィールドがあれば `psc` フィールドを台本データとして取得するようにしています。

あとは `PSc` クラスのコンストラクタを呼んでインスタンスを返しているだけです。
プロパティが足りないなど問題があった場合は例外がスローされます。

# データを表示してみる

ここまで見てきたように、読込んだデータは `Data` コンポーネントの `showPSc` イベントにより、親である `App` コンポーネントの `psc` プロパティにセットされます。

これを画面に反映するために、`App` コンポーネントを以下のようにしました。

```html:src/App.svelte (抜粋)
<script lang="ts">
  /* 中略 */
  let psc: PSc | undefined
  $: title = psc?.title ?? '台本ビューア'
  /* 中略 */
</script>

<main bind:this="{main}" style="top: {HEADER_HEIGHT}px">
  <Viewer
    bind:this="{viewer}"
    bind:psc
  />
</main>

<Header
  bind:title
  on:openToc="{() => { tocIsOpen = true }}"
  on:openMainMenu="{() => { menuIsOpen = true }}"
/>
.....
```

`main` 要素の中に `Viewer` コンポーネントを置いて、ここに台本の内容を表示することにしました。
`Viewer` コンポーネントの中身はこのあと見ていきます。

また、`Header` コンポーネントに台本のタイトルを表示するために `title` プロパティを追加して、そこにバインドする変数を [リアクティブ宣言](https://svelte.jp/tutorial/reactive-declarations) しました。
プロパティ名とバインドする変数名が同じなので、単に `bind:title` のように書けます ([→参考](https://svelte.jp/tutorial/textarea-inputs))。

## Header コンポーネント

先に `Header` コンポーネントを見ていきます。
これは簡単です。`title` というプロパティを宣言して表示しているだけです ([→ソース全文](https://github.com/satamame/pscv/blob/qiita.05/src/components/Header.svelte))。

```html:src/components/Header.svelte (抜粋)
<script lang="ts">
  /* 中略 */
  export let title: string
</script>

<header style="background-color: {HEADER_COLOR}; height: {HEADER_HEIGHT}px;">
  .....
  <h1>{title}</h1>
  .....
</header>
.....
```

## Viewer コンポーネント

名前のとおり台本の表示まわりの処理を担うコンポーネントにするつもりですが、今はとりあえずデータが読込まれていることの確認だけします。

```html:src/components/Viewer.svelte
<script lang="ts">
  import type { PSc } from '../lib/psc'

  export let psc: PSc | undefined
</script>

<div>
  {#if psc}
    {#each psc.lines as line }
      <p>{line.name ?? ''} : {line.text ?? ''}</p>
    {/each}
  {/if}
</div>
```

プロパティ `psc` を宣言して、`showPSc` イベントからの流れで受け取れるようにしています。
`PSc` オブジェクトの `lines` というプロパティが台本の行を含む配列になっているので、これを `#each` で回して表示しています。
各要素は `PScLine` オブジェクトになっています。今回はとりあえず `name` プロパティと `text` プロパティを単純に連結して表示しています。
これらのプロパティは両方ともオプショナルなので、Null 合体演算子で '' (空の文字列) にフォールバックしています。

ここまで実装して「台本データ」パネルからサンプルを読込むと、こんな表示になります。

![05_viewer.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339253/799ed5a2-8994-04bb-6851-aee1d156191e.png)

見た目はともかく、データが読込まれていることが分かると思います。
重要なポイントは、`App` コンポーネントでは特に関数など定義せずに、`psc` プロパティがセットされるだけでリアクティブに台本が表示されるということです。

# 余談

今回からコメントに積極的に日本語を使うようにしました (GitHub のコミットのコメントにも)。