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

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

**使っている技術**

- Svelte (チュートリアル終えたくらい)
- PWA (簡単なアプリを作れるくらい)
- TypeScript (初心者)

記事リンク

- 次回 : [<第2回> PWA にする](https://qiita.com/satamame/items/2af28e120676df9ab375)
- [シリーズ記事一覧](#シリーズ記事一覧)

# <第1回> 基本のコンポーネントを作る

**今回の概要**

- 今回はプロジェクトを作るところからです。
- まだ PWA にはしていません。
- 基本的な UI としてヘッダとドロワーをコンポーネントにするところまでです。

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

## 元の台本ビューアについて

元になっている台本ビューアのソースコードは [GitHub のこちらのコミット](https://github.com/satamame/pscv/tree/0.0.2) にあります。
実際に動くものは以下にあります。

- [satamame.github.io/pscv/](https://satamame.github.io/pscv/)
    - Svelte 版ができるまではここで公開します。
- [satamame.ddns.me:5380/pscv002/](https://satamame.ddns.me:5380/pscv002/)
    - 継続的に公開しますが、たまにメンテ中になります。

**機能**

- シーン目次
- 台本内検索
- 文字サイズ変更
- 横書き・縦書き

![01_pscv002.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339253/04799a66-4bc3-3637-725b-9556b7eff39d.png)

# プロジェクト作成

Windows 10 で、node, npm, yarn がインストールされている前提です。
私の環境では以下のバージョンでした。

- node : 16.17.0
- npm : 8.18.0
- yarn : 1.22.19

プロジェクトは [Vite](https://ja.vitejs.dev) で作って、TypeScript も使える設定にしました。

```
> yarn create vite pscv --template svelte-ts
```

次にプロジェクトフォルダ (この場合は pscv) に入ってパッケージのインストールをしました。

```
> cd pscv
> yarn
```

これで node_modules フォルダや設定ファイルが出来ます。
以下のコマンドで開発サーバが起動します。

```
> yarn dev
```

## TypeScript を使う場合の変更

tsconfig.json に以下の設定を追加しました。
この設定をしないと、コンポーネントのファイル名などを大文字始まりにした時にエラーになるようです。

```json:tsconfig.json (抜粋)
{
  "compilerOptions": {
    (中略)
    "forceConsistentCasingInFileNames": false  <- これを追加
  },
}
```

## マウント先を変える

インストール直後は、index.html の `body` 内の `div` 以下にアプリがマウントされるようになっています。
これを `body` 直下にマウントするようにしました。

理由は、これから作る `main` 要素や `Header` コンポーネントを `body` 直下に置きたいからです。
もし `div` をすげ替えるなど SPA 的なことをするなら、この構造は変えない方が良いです。

やり方は、まず index.html の `body` 内の `div` を削除します ([→ソース全文](https://github.com/satamame/pscv/blob/qiita.01/index.html))。

```html:index.html (抜粋)
  <body>
    <div id="app"></div>  <!-- この行を削除 -->
    <script type="module" src="/src/main.ts"></script>
  </body>
```

そして src/main.ts の `target` を変更します ([→ソース全文](https://github.com/satamame/pscv/blob/qiita.01/src/main.ts))。

```typescript:src/main.ts (抜粋)
const app = new App({
  target: document.getElementById('app')  // 変更前
  target: document.body                   // 変更後
})
```

# 不都合な操作を封じる

src/app.css を以下のようにして、スマホ特有の不都合な操作を封じました ([→ソース全文](https://github.com/satamame/pscv/blob/qiita.01/src/app.css))。

```css:src/app.css (抜粋)
* {
  box-sizing: border-box;
  -webkit-tap-highlight-color: transparent;  /* 強調をなくす */
  touch-action: manipulation;  /* ダブルタップでズームさせない */
}

:root {
  -webkit-text-size-adjust: 100%;  /* Safari 回転対策 */
}

body {
  overscroll-behavior: none;  /* ドラッグでリロードさせない */
}
```

リロードさせたくない理由は、Android デバイスの Back ボタンをフックするために `window.history` を使うからです。

# ヘッダ

ヘッダに台本のタイトルとドロワー (目次とメニュー) のボタンを表示するようにして、これを `Header` コンポーネントとしました。

![01_header.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339253/6ba7bddc-8242-717e-97be-a1427cb5eea4.png)

ヘッダなのでスクロールで動かないように `position: fixed;` にします。
それ以外のポイントとして (1) 画像のパスを正しく設定する, (2) ボタンのイベントを親コンポーネントに渡す, があります。

## 画像のパスを正しく設定する

画像は以下のパスに配置しました。
※ Google の [Material Icons](https://fonts.google.com/icons?icon.style=Rounded&icon.set=Material+Icons) からダウンロードして使っています。

```
pscv
 └─public
    └─ui_icon
       ├─menu_open_black_24dp.svg
       └─toc_black_24dp.svg
```

public フォルダ内のコンテンツは、ビルドするとアプリのルートにコピーされます。

`Header` コンポーネントでは画像のパスを以下のように指定しました ([→ソース全文](https://github.com/satamame/pscv/blob/qiita.01/src/components/Header.svelte))。

```html:src/components/Header.svelte (抜粋)
<script lang="ts">
  import tocIcon from '/ui_icon/toc_black_24dp.svg'
  import menuIcon from '/ui_icon/menu_open_black_24dp.svg'
</script>

<header>
  <button>
    <img alt="目次" src="{tocIcon}" />
  </button>
  <h1>タイトル</h1>
  <button>
    <img alt="メニュー" src="{menuIcon}" />
  </button>
</header>
```

`import` 文が何をするかというと、パスをサーバルートからのパスに変換します ([→参考](https://vitejs.dev/guide/assets.html#importing-asset-as-url))。
Vite のデフォルトではアプリのルートがサーバルートなのでこの変換は必要ありません。
アプリのルートを変える場合は vite.config.ts に [base](https://vitejs.dev/config/shared-options.html#base) というコンフィグを追加します (そしてデプロイ時にその位置にデプロイします)。

```typescript:vite.config.ts (抜粋)
export default defineConfig({
  base: '/pscv/', // これを追加
  plugins: [svelte()]
})
```

## ボタンのイベントを親コンポーネントに渡す

やり方は [公式のチュートリアル](https://svelte.jp/tutorial/component-events) に書いてあるので、その通りにやりました ([→ソース全文](https://github.com/satamame/pscv/blob/qiita.01/src/components/Header.svelte))。

```html:src/components/Header.svelte (抜粋)
<script lang="ts">
  import { createEventDispatcher } from 'svelte'

  const dispatch = createEventDispatcher()
</script>

<header style="background-color: {headerColor};">
  <button class="left-button" on:click="{() => dispatch('openToc')}" >
    <img alt="目次" src="{tocIcon}" />
  </button>
  <h1>タイトル</h1>
  <button class="right-button" on:click="{() => dispatch('openMainMenu')}" >
    <img alt="メニュー" src="{menuIcon}" />
  </button>
</header>
```

親コンポーネント (src/App.svelte) の方は以下のような感じです ([→ソース全文](https://github.com/satamame/pscv/blob/qiita.01/src/App.svelte))。
目次用の `Toc` コンポーネントとメニュー用の `MainMenu` コンポーネントはまだありませんが、プロパティ `xxIsOpen` の `true`/`false` で表示/非表示を切り替える仕組みを作っておきます。

```html:src/App.svelte (抜粋)
<script lang="ts">
  import Header from "./components/Header.svelte"
  import Toc from "./components/Toc.svelte"
  import MainMenu from "./components/MainMenu.svelte"

  let tocIsOpen = false
  let menuIsOpen = false
</script>

<Header
  on:openToc="{() => { tocIsOpen = true }}"
  on:openMainMenu="{() => { menuIsOpen = true }}"
/>
{#if tocIsOpen}
  <Toc />
{/if}
{#if menuIsOpen}
  <MainMenu />
{/if}
```

# main 要素

`main` 要素は台本の内容を表示する場所です。
元の台本ビューアでは `main` も `Header` と同じように `position: fixed;` にしていたのですが (下図の (1))、今回は `position: absolute;` にしました (下図の (2))。

![01_main_layout.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339253/6df3512f-9003-4d82-905d-530f1dfe3e13.png)

何が違うかというと、(2) にするとスマホのブラウザでスクロールした時に、アドレスバー等の UI が隠れて画面を広く使えるようになります。
PWA としてインストールして使う場合には関係ありません。

とりあえず適当な内容を入れて `Header` より先に (Header より背面になるように) 書きます。

```html:src/App.svelte (抜粋)
<main>
  <適当な内容 />
</main>
<Header
  .....
```

スタイルは以下のようにしました (同じ App.svelte の下の方にあります)。

```html:src/App.svelte (抜粋)
<style>
  main {
    position: absolute;
    top: 48px;
    left: 0;
    right: 0;
    padding: 8px 12px 12px 18px;
  }
</style>
```

## 適当な内容を表示するコンポーネント

開発中に適当な内容を表示するためのコンポーネントも作ってみました。
[src/components/LoremIpsum.svelte](https://github.com/satamame/pscv/blob/qiita.01/src/components/LoremIpsum.svelte) にあるので内容はそちらをご覧ください。
たとえば以下のようにすると、`main` の中に適当な `div` を4個表示します。

```html:src/App.svelte (抜粋)
<script lang="ts">
  /* 中略 */
  import LoremIpsum from './components/LoremIpsum.svelte'
</script>

<main>
  <LoremIpsum blockCount="{4}" />
</main>
```

![01_lorem_ipsum.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339253/ad0beadf-dfc0-d626-4926-49e771b0f823.png)

# ドロワー

ヘッダの左右のボタンを押して表示する `Toc` と `MainMenu` はどちらもドロワーとして作りました。
作りは同じですので `Toc` コンポーネントだけ見ていきます。

`Toc` コンポーネントはドロワー本体のほかにオーバーレイも表示します。
これもコンポーネントとして作りました。

![01_overlay.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339253/3a46c12f-4ad1-00cb-0673-7b9f71833374.png)

## オーバーレイ

`Overlay` コンポーネントは、モーダルな UI を表示する時に使えるような、単に全画面を覆うだけの `div` 要素にしました。
`click` イベントを親にフォワードするよう [on:](https://svelte.jp/docs#template-syntax-element-directives-on-eventname) ディレクティブをつけてあります。

```html:src/components/Overlay.svelte
<script lang="ts">
  export let color = 'black'
  export let opacity = 0.25
</script>

<div
  style:background-color="{color}"
  style:opacity
  on:click
></div>

<style>
  div {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
  }
</style>
```

## ドロワーを閉じる仕組み

オーバーレイをクリックするか「閉じる」ボタンをクリックすることで、`close` イベントを親にフォワードするようにしました。
`Toc` コンポーネントの内容は以下のような感じです。
ドロワー部分のスタイル等は [ソース全文](https://github.com/satamame/pscv/blob/qiita.01/src/components/Toc.svelte) をご覧ください。

```html:src/components/Toc.svelte (抜粋)
<script lang="ts">
  import { createEventDispatcher } from 'svelte'
  import Overlay from "./Overlay.svelte"
  import LoremIpsum from './LoremIpsum.svelte'
  import closeIcon from '/ui_icon/close_black_24dp.svg'

  const dispatch = createEventDispatcher()

  function close() {
    dispatch('close')
  }
</script>

<div class="overlay overlay-gone" bind:this="{overlay}" >
  <Overlay on:click="{close}" />
</div>
<div class="panel panel-gone" bind:this="{panel}" >
  <h1>目次</h1>
  <button class="close-button">
    <img alt="閉じる" src="{closeIcon}" on:click="{close}" />
  </button>
  <適当な内容 />
</div>
```

親コンポーネント (src/App.svelte) の方は以下のように `close` イベントを処理するようにしました ([→ソース全文](https://github.com/satamame/pscv/blob/qiita.01/src/App.svelte))。

```html:src/App.svelte (抜粋)
{#if tocIsOpen}
  <Toc on:close="{() => { tocIsOpen = false }}" />
{/if}
{#if menuIsOpen}
  <MainMenu on:close="{() => { menuIsOpen = false }}" />
{/if}
```

これで一応ドロワーが出たり消えたりするのですが、アニメーションをつけたいところです。

## ドロワーのアニメーション

[Svelte の transition](https://svelte.jp/docs#template-syntax-element-directives-transition-fn) でドロワーとオーバーレイを同時にアニメーションする方法が分からなかったので、スクリプトで制御するようにしました。
前節のソースコードに余計な `class` や `bind` があったのはそのためです。

:::note info
2022/10/11 追記
[第3回](https://qiita.com/satamame/items/36c6761d363ca3894824) の[「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)で、ドロワーのアニメーションの実装をもっとすっきりした書き方に改善しました。
:::

ドロワー本体は `panel` というクラスです。これと、「閉じる時にどう変化するか」を表す `panel-gone` というクラスを用意しました。

```html:src/components/Toc.svelte (抜粋)
<style>
  .panel {
    position: fixed;
    top: 0;
    left: 0;
    min-width: 180px;
    max-width: 100%;
    max-height: 100%;
    background: white;
    overflow: auto;
    transition: transform 0.2s, opacity 0.2s;
  }
  .panel-gone {
    transform: translateX(-180px);
    opacity: 0;
  }
  /* 中略 */
</style>
```

上記のスタイルにすることで、閉じる時 (`panel-gone` クラスを適用した時) は 180px だけ左に移動しつつ透明になり、開く時 (`panel-gone` クラスを取消した時) はその逆の動きをするようになります。

オーバーレイは、移動はせずに透明度だけ変えれば良いです。

```html:src/components/Toc.svelte (抜粋)
<style>
  /* 中略 */
  .overlay {
    transition: opacity 0.2s;
  }
  .overlay-gone {
    opacity: 0;
  }
  /* 中略 */
</style>
```

### アニメーションのタイミング

次に、これらのクラスを適用する (または取消す) タイミングについてです。

まず、コンポーネントがマウントされた時に「閉じた状態から開いた状態へ」アニメーションして欲しいので、`panel-gone` と `overlay-gone` を適用した状態から始めます。

⇒ 前節「ドロワーを閉じる仕組み」のソースコード参照

そして開くアニメーションのために [onMount()](https://svelte.jp/docs#run-time-svelte-onmount) で「閉じた状態のクラス」を取消す処理を設定します。
この時レンダリングを待つ必要があるらしいので、`setTimeOut()` で遅延させるようにしました ([Svelte の tick()](https://svelte.jp/docs#run-time-svelte-tick) では上手くいきませんでした)。

```html:src/components/Toc.svelte (抜粋)
<script lang="ts">
  import { createEventDispatcher, onMount } from 'svelte'
  /* 中略 */
  let panel
  let overlay
  let disabled = false

  onMount(async () => {
    setTimeout(() => {
      overlay.classList.remove('overlay-gone')
      panel.classList.remove('panel-gone')
    }, 0)
  })
</script>
```

`disabled` プロパティは閉じる時に使います。

閉じる時はアニメーションの完了を待ってから `close` イベントを親にフォワードするように、先ほどの関数を書き換えます。
アニメーション中に再びイベントを処理しないようにフラグ (`disabled` プロパティ) を立てるようにしました。

```html:src/components/Toc.svelte (抜粋)
<script lang="ts">
  /* 中略 */
  function close() {
    if (disabled) { return }
    disabled = true

    overlay.classList.add('overlay-gone')
    panel.classList.add('panel-gone')

    setTimeout(() => {
      dispatch('close')
    }, 200)
  }
</script>
```

`MainMenu` コンポーネントも、やっていることは同じです ([→ソース全文](https://github.com/satamame/pscv/blob/qiita.01/src/components/MainMenu.svelte))。

# スクロールを固定する

ドロワー等のモーダルな UI を開いている時は、台本をスクロールできないようにしたいと思いました。
オーバーレイに色を入れているのも、それを想定してのことです。

:::note info
ここでのスクロールを固定する方法は問題があったので、[第7回](https://qiita.com/satamame/items/27439b805e3a0cccdcc7) で改善しています。
:::

スクロールを固定する機能は `App` コンポーネントの中で完結しますが、HTML ルート要素のスタイルは src/app.css の方で変えておくのが良いです。

```css:src/app.css (抜粋)
:root {
  width: 100%;
  /* 中略 */
}
```

これは、スクロールを固定するのにルート要素を `position: fixed;` にした時、ルート要素の幅が変わらないようにするためです。

`position: fixed;` にするとスクロール位置もリセットされるので、その分だけ中身を動かして移動量を相殺しないと見た目が不自然になります。

![01_scroll_lock.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339253/60a51ab6-38a9-2e05-ffcc-d17a2c74f3ee.png)

以下のようなコードを書くことで、モーダルになったら HTML ルート要素のスクロールを固定して中身の位置を調整し、モーダルを解除したら元の状態を復元するようにしました。

```html:src/App.svelte (抜粋)
<script lang="ts">
  /* 中略 */
  let main
  $: isModal = tocIsOpen || menuIsOpen
  $: if (main) {
    if (isModal) {
      const scrollTop = document.documentElement.scrollTop
      document.documentElement.style.position = 'fixed'
      main.style.top = `${48 - scrollTop}px`
    } else {
      const scrollTop = 48 - main.offsetTop
      main.style.top = '48px'
      document.documentElement.style.position = 'static'
      document.documentElement.scrollTop = scrollTop
    }
  }
</script>

<main bind:this="{main}" >
  <LoremIpsum blockCount="{4}" />
</main>
```

`isModal` はモーダル状態を表し、[リアクティブ宣言](https://svelte.jp/docs#component-format-script-3-$-marks-a-statement-as-reactive) しているので `Toc` か `MainMenu` が開いたり閉じたりするたびに更新されます。
その下の `if` 文もリアクティブ宣言されているので、`isModal` の値が変わるたびに実行されます。
モーダルになった時は上図の右側の状態になるようにして、モーダルが解除されたら左側の状態に戻るようにすれば出来上がりです。

ただし、今後は縦書き表示に対応するので、スクロール周りはいろいろと変更が入ると思います。

# 参考リンク

- [Introduction / Basics • Svelte Tutorial](https://svelte.jp/tutorial/basics)
- [Docs • Svelte](https://svelte.jp/docs)
- [vite/packages/create-vite at main · vitejs/vite](https://github.com/vitejs/vite/tree/main/packages/create-vite)
- [Static Asset Handling | Vite](https://vitejs.dev/guide/assets.html)
- [Shared Options | Vite](https://vitejs.dev/config/shared-options.html#base)
- [Material Symbols and Icons - Google Fonts](https://fonts.google.com/icons?icon.set=Material+Icons)

# シリーズ記事一覧

- [<第1回> 基本のコンポーネントを作る](#)
- [<第2回> PWA にする](https://qiita.com/satamame/items/2af28e120676df9ab375)
- [<第3回> 更新プロンプトを出す](https://qiita.com/satamame/items/36c6761d363ca3894824)
- [<第4回> Back ボタン対応](https://qiita.com/satamame/items/ff499e66fe55aa0fd551)
- [<第5回> データを読込む](https://qiita.com/satamame/items/529b016e95f6d412dbad)
- [<第6回> 更新の動作の改善](https://qiita.com/satamame/items/74bff0c6e653ffe55f88)
- [<第7回> 目次によるジャンプ](https://qiita.com/satamame/items/27439b805e3a0cccdcc7)
- [<第8回> 台本をローカル DB に保存](https://qiita.com/satamame/items/2e9794d6b084b86b2044)
- [<第9回> 台本一覧を D&D で並べ替え](https://qiita.com/satamame/items/167d71f9666ffe8a329c)
- [<第10回> Svelte, TS, Dexie の更新](https://qiita.com/satamame/items/bb0e3acd3e2b47ffa3a2)
- [<第11回> Svelte 5 対応](https://qiita.com/satamame/items/430d53bff0dc259d613a)