PWA で作った台本ビューア を Svelte で作り直すことにしたので、やったことをメモしていきます。
- 前回 : <第4回> Back ボタン対応
- 次回 : <第6回> 更新の動作の改善
- シリーズ記事一覧
<第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
クラスと同じ構造を作ります (→ソース全文)。
/** 行の種類 */
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' を追加します。
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
コンポーネントのソース全文は こちら にあります。
<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>
.....
クリックした時に親コンポーネントの openData()
を呼ぶようにしたので、App.svelte を以下のようにします。
Data
コンポーネントはまだありませんが、あるものとして関連する処理を追加してあります (→ソース全文)。
<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 上のソース)
<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 の中で定義しています。
export const SAMPLES = [
{ title: 'マダムと謎のいいがかり', path: 'sample/madam.json' },
{ title: 'ヘルプ', path: 'sample/help.json' }
] as const
これをプルダウンの中で #each
で回して、各要素の title
プロパティを表示しています。
選択した値が sampleSelect
という変数に入るように (バインド) してありますが、この「値」は SAMPLES
の要素、つまりオブジェクトになります。ここが Svelte の面白いところです (→参考)。
ボタンのハンドラである showSample()
では、今から作る関数で台本データを取得して、それを showPSc
イベントに乗せて親コンポーネント (App.svelte) に 渡しています。
それを受け取った親コンポーネントが何をするかは後で見ることにして、台本データを取得する関数 PSc.fromUrl()
について見てみます。
データを読込む関数
データを読込む関数は PSc
オブジェクトを返すので、PSc
クラスのクラスメソッドにしました。
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' 等という値を入れて呼び出せば、そこから台本データを取ってきます。
この行についても説明します。
// 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
コンポーネントを以下のようにしました。
<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
というプロパティを宣言して表示しているだけです (→ソース全文)。
<script lang="ts">
/* 中略 */
export let title: string
</script>
<header style="background-color: {HEADER_COLOR}; height: {HEADER_HEIGHT}px;">
.....
<h1>{title}</h1>
.....
</header>
.....
Viewer コンポーネント
名前のとおり台本の表示まわりの処理を担うコンポーネントにするつもりですが、今はとりあえずデータが読込まれていることの確認だけします。
<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 合体演算子で '' (空の文字列) にフォールバックしています。
ここまで実装して「台本データ」パネルからサンプルを読込むと、こんな表示になります。
見た目はともかく、データが読込まれていることが分かると思います。
重要なポイントは、App
コンポーネントでは特に関数など定義せずに、psc
プロパティがセットされるだけでリアクティブに台本が表示されるということです。
余談
今回からコメントに積極的に日本語を使うようにしました (GitHub のコミットのコメントにも)。