0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-12-19

PWA で作った台本ビューアSvelte で作り直すことにしたので、やったことをメモしていきます。

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

今回の概要

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

今回使うソースコードは GitHub のこちらのコミット にあります。

台本データ

このアプリで扱う台本データは JSON です。
具体的なフォーマットは playscript という Python パッケージに準じます。

台本データの例
{
  "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 クラスと同じ構造を作ります (→ソース全文)。

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' を追加します。

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 コンポーネントを開くようにした時と同じです (→参考)。Android の Back ボタンにも対応します (→参考)。
MainMenu コンポーネントのソース全文は こちら にあります。

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

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

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}
.....

「台本データ」パネル

開けたり閉めたりする処理は「バージョン情報」パネルと同じなので、今回のテーマに関わる箇所だけ抜粋します (→ソース全文)。
Android の Back ボタンのハンドラについても割愛します (→GitHub 上のソース)

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 の中で定義しています。

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

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

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

データを読込む関数

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

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' 等という値を入れて呼び出せば、そこから台本データを取ってきます。

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

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

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

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

データを表示してみる

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

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

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 プロパティを追加して、そこにバインドする変数を リアクティブ宣言 しました。
プロパティ名とバインドする変数名が同じなので、単に bind:title のように書けます (→参考)。

Header コンポーネント

先に Header コンポーネントを見ていきます。
これは簡単です。title というプロパティを宣言して表示しているだけです (→ソース全文)。

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 コンポーネント

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

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

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

余談

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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?