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

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

- 前回 : [<第7回> 目次によるジャンプ](https://qiita.com/satamame/items/27439b805e3a0cccdcc7)
- 次回 : [<第9回> 台本一覧を D&D で並べ替え](https://qiita.com/satamame/items/167d71f9666ffe8a329c)
- [シリーズ記事一覧](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)

# <第8回> 台本をローカル DB に保存

今回のテーマは、読込んだ台本データを IndexedDB というローカル DB に保存することです。
あと、関係ないけど Chrome mobile での「タップして検索」についても書いておきます。

**今回の概要**

- Chrome mobile でタップした時の「タップして検索」が出ないようにします。
- IndexedDB を扱いやすくするために Dexie.js を導入します。
- 台本データの追加、読込み、削除を実装します。

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

# 「タップして検索」

「タップして検索」はスマホ版の Chrome の機能で、Web ページ上の単語を触ると画面の下から検索用のパネルが生えてくるというものです。
どうもこの機能がデフォルトで ON だったらしく、その設定が PWA にも影響しています。
また PWA を Vivaldi でインストールした場合は Chrome の設定に関係なく ON になるようです。
![08_tap_to_search.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339253/f6811ad1-e929-8de3-d6fb-358dc52b5b0a.png)

## 「タップして検索」が出ないようにする

タップしただけでこれが出るのはアプリとしてはバグっぽい動きになってしまうので、これを出ないようにします。
どうやら TAB キーでのフォーカスのターゲットにならなければ良いようなので、`body` タグに以下の属性をつけます。

```html:index.html (抜粋)
  <body tabindex="-1">
```

TAB キーを使うことは想定していないので、このアプリに関してはこれで解決とします。

ちなみにこの対応をしても、Chrome の設定で「タップして検索」を OFF にしても、長押しをすれば検索パネルは出てきます。
それも出したくない場合はスタイルに `user-select: none;` を追加して、文字を選択すること自体できなくすれば良いです。

# IndexedDB

[IndexedDB](https://developer.mozilla.org/ja/docs/Web/API/IndexedDB_API) はブラウザがローカルにデータを保存するためのデータベースです。
もちろんブラウザの機能なので PWA から使えます。
ローカルにデータを保存する方法は他にも [localStorage](https://developer.mozilla.org/ja/docs/Web/API/Window/localStorage) 等がありますが、台本データの保存には IndexedDB を採用します。
理由は、扱える容量が大きいことと、データベースとしてのインターフェイスが備わっていることです。

# Dexie.js

[Dexie.js](https://dexie.org) は IndexedDB を扱いやすくするラッパーを提供します。
JavaScript で API をちまちま書く代わりに、`Table` オブジェクトのメソッドでテーブル操作をしたり、メソッドチェーンでフィルタをかけたりといった書き方ができるようになります。

## liveQuery について

Dexie.js にはリアクティブなフレームワークで使える `liveQuery()` という関数があります。
Svelte の場合、[liveQuery()](https://dexie.org/docs/Tutorial/Svelte#5-create-a-component-that-queries-data) で DB への問合せを [Store](https://svelte.jp/docs#component-format-script-4-prefix-stores-with-$-to-access-their-values) にして、テンプレートで参照することで常に画面に DB の内容を反映するようにできます。

言葉で言っても分かりづらいと思うので、あとで台本一覧のコンポーネントの説明をする時に、どういう事か説明します。

## インストール

```
> yarn add dexie
```

# スキーマを決める

IndexedDB はいわゆる NoSQL なので、台本のデータを「一覧表示用」と「台本の中身」に分けて保存することにしました。

一覧表示用の画面は以下のイメージです。
第5回で作った [「台本データ」パネル](https://qiita.com/satamame/items/529b016e95f6d412dbad#%E5%8F%B0%E6%9C%AC%E3%83%87%E3%83%BC%E3%82%BF%E3%83%91%E3%83%8D%E3%83%AB) をこれに差し替えます。

![08_data_list.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339253/7845b986-3bdd-3b45-772a-d9f890dec9ea.png)

この表示に必要な各アイテムの情報は以下になります。

1. 並び順を憶えておくためのソートキー
1. 台本の名前
1. 関連する「台本の中身」の ID

次に「台本の中身」ですが、以下の情報を持たせることにしました。

1. 台本データの JSON 文字列
1. 読込み元のタイプ (サンプル  | ネット | ファイル)
1. 読込み元の URL
1. ユーザーデータ

以上の設計で Dexie を使ってスキーマを定義すると、以下のようになります。

```typescript:src/lib/db.ts (抜粋)
import Dexie from 'dexie'
import type { Table } from 'dexie'

import type { UserData } from './user-data'

/** 台本インデックスのレコード */
interface ScriptIndex {
  id?: number       // プライマリキー
  sortKey: number   // ソートキー (手動並べ替え用)
  name: string      // 一覧に表示する名前
  scriptId: number  // 台本データの ID
}

/** 台本データのレコード */
interface ScriptData {
  id?: number         // プライマリキー
  pscJson: string     // 台本データの JSON 文字列
  srcType: string     // 読込み元のタイプ (サンプル | ネット | ファイル)
  url: string         // 読込み元の URL
  userData: UserData  // 拡張用
}

/** スキーマの定義 */
class PscvDB extends Dexie {
  scriptIndex!: Table<ScriptIndex>  // 台本インデックス用のテーブル
  scriptData!: Table<ScriptData>    // 台本データ中身用のテーブル

  /** コンストラクタ */
  constructor() {
    // データベース名を与える
    super('pscvDB')

    // 各テーブルのインデックスを付与する列を設定
    // インデックスを付与すると検索条件に使える
    this.version(1).stores({
      scriptIndex: '++id, sortKey, &scriptId',
      scriptData: '++id',
    })
  }
}

export const db = new PscvDB()
```

ちょっとややこしいですが、「台本インデックス」と呼んでいるのは一覧表示に使う「順番と名前だけを持つレコード」のことで、データベースのインデックスとは関係ありません。

最後で `export` している `db` が、IndexedDB の 'pscvDB' というデータベースを操作するためのインスタンスになります。
詳しくは Dexie.js の公式ドキュメント [Get started with Dexie in Svelte](https://dexie.org/docs/Tutorial/Svelte) をご覧ください。
`this.version().stores()` が何をやっているかについては [**こちら**](https://dexie.org/docs/Version/Version.stores()) に説明があります。

台本データの中の `userData` というキーについて補足しますと、これは将来的な拡張のためのプレースホルダです。
今はプロパティのない空のオブジェクトになっています。

```typescript:src/lib/user-data.ts
export class UserData {

}
```

この中にプロパティを増やしても (それをインデックスにしないのであれば) スキーマを変える必要はありません (と思います)。
こういう柔軟性が NoSQL の良いところですね。

# 台本を追加する

台本を追加する画面は以下のようなパネルにしました。
![08_data_add.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339253/6e45deaa-c957-91a3-40bc-acefca385a2d.png)
今回はデータベースの話なのでこの画面の説明はしません。処理の詳細については [ソースコード](https://github.com/satamame/pscv/blob/qiita.08/src/components/DataAdd.svelte) を参考にしてみてください。

最終的にこのパネルでは以下の `addScript()` メソッドを呼んでいます。

```typescript:src/lib/db.ts (抜粋)
class PscvDB extends Dexie {

  /* 中略 */

  /** 台本データを DB に追加する */
  public async addScript(
    name: string,     // 一覧表示用の台本の名前
    pscJson: string,  // 台本データの JSON 文字列
    srcType: string,  // 読込み元のタイプ (sample | net | file)
    url: string       // 読込み元の URL またはファイル名
  ): Promise<number> {
    return this.transaction('rw', this.scriptIndex, this.scriptData, async () => {
      // 台本データ追加
      const scriptId = await this.scriptData.add({
        pscJson, srcType, url, userData: {}
      }) as number

      // 台本インデックス更新
      let newSortKey = 2
      await this.scriptIndex.orderBy('sortKey').modify(scIndex => {
        scIndex.sortKey = newSortKey++
      })
      this.scriptIndex.add({ sortKey: 1, name, scriptId })

      // 新規 ID を返す
      return scriptId
    })
  }
}
```

scriptData と scriptIndex の2つのテーブルを合わせて更新する必要があるので、トランザクションで処理しています。
追加した台本が一覧表示の先頭に来るように、scriptIndex の既存のレコードのソートキーを `2` から始まる連番に振りなおしています (「台本インデックス更新」と書いてあるところ)。

これを実行すると scriptData と scriptIndex のレコードが追加され、ブラウザの開発ツールで確認できます。
![08_devtool_data_added.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339253/f183b363-7e4e-a202-e801-1a08a66233e1.png)

# 一覧表示用の「台本データ」パネル

上のスクリーンショットの「台本データ」パネルは以下のようにして表示しています ([→ソース全文](https://github.com/satamame/pscv/blob/qiita.08/src/components/DataList.svelte))。

```html:src/components/DataList.svelte
<script lang="ts">
  import { liveQuery } from "dexie"
  import { HEADER_HEIGHT } from '../lib/const'
  import { db } from '../lib/db'
  import DataCell from './DataCell.svelte'

  /* 中略 */

  // リストに DB の内容が自動的に反映されるようにする
  let scIndexes = liveQuery(
    () => db.scriptIndex.orderBy('sortKey').toArray()
  )

  /* 中略 */
</script>

<div class="panel" class:gone>
  <!-- 中略 -->

  <div class="container" style="top: {HEADER_HEIGHT}px;">
    {#if $scIndexes}
      {#each $scIndexes as scIndex}
        <DataCell
          scIndex="{scIndex}"
          on:showPSc="{() => showPSc(scIndex.scriptId)}"
          on:showInfo="{() => showInfo(scIndex.id)}"
        />
      {/each}
    {/if}
  </div>

  <!-- 中略 -->
</div>
```

ここで `liveQuery()` を使っています。
`liveQuery()` に渡している引数は DB に問い合わせた結果を返す**関数**です。
具体的には、「台本インデックス (scriptIndex) テーブルの全レコードを `sortKey` 順にして配列にしたもの」を返す関数です。

`liveQuery()` の結果を代入された `scIndexes` は、「DB の内容が変わったら更新される Store」になります。
これをテンプレートの中で `$scIndexes` として[「`$` を付けて参照」](https://svelte.jp/docs#component-format-script-4-prefix-stores-with-$-to-access-their-values)しているので、DB の内容が変わったらリストが更新されるようになります。

DataCell というコンポーネントは、右端のアイコン ('ⓘ') を押されると `showInfo` をディスパッチして、それ以外の部分を押されると `showPSc` をディスパッチするようになっています。
DataCell のソースコードは [こちら](https://github.com/satamame/pscv/blob/qiita.08/src/components/DataCell.svelte) です。

`showPSc()` はパネルを閉じてクリックされた台本を表示します。
`showInfo()` は台本の情報を表示するパネルを開きます。
これらの処理の詳細は [GitHub 上のソース](https://github.com/satamame/pscv/blob/qiita.08/src/components/DataList.svelte) をご覧ください。

台本の情報を表示するパネルには「削除」ボタンをつけて、ここから台本データを削除できるようにしました。
![08_data_info.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339253/8474e310-41e5-0bf5-3816-9b53654dd6e9.png)

# 台本を削除する

「台本の情報」パネルは先ほどの「台本データ」(一覧) パネルの子コンポーネントになっています。
先ほどのコードでは省略しましたが、以下のように組み込んでいます。

```html:src/components/DataList.svelte (抜粋)
{#if infoIsOpen}
  <DataInfo
    bind:this="{dataInfo}"
    bind:scIndexId="{infoScIndexId}"
    on:close="{() => { infoIsOpen = false }}"
  />
{/if}
```

ここで注目してほしいのが、`DataInfo` コンポーネントが台本を削除するイベントをディスパッチ**していない**事です。
実は前述の台本を追加するパネルも台本を追加するイベントをディスパッチしていませんでした。

これは、台本一覧が `liveQuery()` を使っていることで DB を更新すれば結果が自動的に反映されるため、子コンポーネントが親コンポーネントに対して何かする必要がないからです。

`DataInfo` コンポーネントは「削除」を押されると以下の `deleteScript()` メソッドを呼んで自らを閉じます ([→ソースコード](https://github.com/satamame/pscv/blob/qiita.08/src/components/DataInfo.svelte))。

呼ばれる `deleteScript()` メソッドはこんな感じです。

```typescript:src/lib/db.ts (抜粋)
class PscvDB extends Dexie {

  /* 中略 */

  /** 台本データを DB から削除する */
  public async deleteScript(scriptId: number): Promise<void> {
    return this.transaction('rw', this.scriptIndex, this.scriptData, async () => {
      // 台本データ削除
      await this.scriptData.delete(scriptId)
      // 台本インデックス削除
      const scIndex = await this.scriptIndex.get({ scriptId })
      await this.scriptIndex.delete(scIndex.id)
    })
  }
}
```

台本インデックスは削除してもソート順は変わらないので、トルツメ等せずに単に削除しています。