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

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

- 前回 : [<第6回> 更新の動作の改善](https://qiita.com/satamame/items/74bff0c6e653ffe55f88)
- 次回 : [<第8回> 台本をローカル DB に保存](https://qiita.com/satamame/items/2e9794d6b084b86b2044)
- [シリーズ記事一覧](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)

# <第7回> 目次によるジャンプ

今回のテーマは、読込んだ台本データの目次を作ってそこから各見出しにジャンプすることです。
しかしその前に、スクロールロックの改善について説明します。

スクロールロックについては [「<第1回> 基本のコンポーネントを作る」](https://qiita.com/satamame/items/3e0683a1fc6ecd6b66ee) の [「スクロールを固定する」](https://qiita.com/satamame/items/3e0683a1fc6ecd6b66ee#%E3%82%B9%E3%82%AF%E3%83%AD%E3%83%BC%E3%83%AB%E3%82%92%E5%9B%BA%E5%AE%9A%E3%81%99%E3%82%8B) という章で説明しましたが、少々問題があって最近その改善をしました。
また、目次から見出しへのジャンプにもスクロールロックの挙動が関わりますので、最初に説明しようと思います。

**今回の概要**

- パネルを開いた時の、ルート要素のスクロールロックを改善します。
- 読込んだ台本データから目次を作ります。
- 目次から選んだ見出しにジャンプする仕組みを作ります。
- 目次を開いた時に、閲覧中のシーンの項目を強調するようにします。

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

# スクロールロックの改善

ここでいう「スクロールロック」とは、メニューなどのパネルを開いた時にドキュメントをスクロールできなくすることです。
パネルをモーダルにする時は下図のようにオーバーレイ (半透明の板) を敷いてパネル以外は操作できなくしますが、なぜかスクロールは出来てしまうので、それを止めます。
![07_scroll_lock.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339253/c4666e4a-8211-805f-d7cd-03c3e4301dae.png)

## 第1回での問題点

第1回でやったスクロールロックの問題点として、ロックを解除する時 (パネルを閉じた時) に別の位置にスクロールしてしまうケースがありました。
ドキュメントのルート要素の `position` を `fixed` から `static` に戻して、すぐにスクロール位置も元に戻していたのですが、少し待ってからスクロール位置を戻さないといけなかったようです。

しかし、少し待ってから (具体的には `setTimeout` のコールバックとして) スクロール位置を戻すようにすると、今度は `position` が `static` になった瞬間が見えるようになりました。
画面全体がちらつくような感じです。

## body-scroll-lock の導入

ちらつきを抑えようと色々やってみたのですが上手く行かず、[body-scroll-lock](https://github.com/willmcpo/body-scroll-lock) というライブラリのお世話になることにしました。

使い方は [body-scroll-lock の GitHub](https://github.com/willmcpo/body-scroll-lock) に書いてあります。
フレームワークごとに使い方の例がありますが、Svelte はありません。
Svelte では `targetElement` 引数をコンポーネントのインスタンスにすれば動きました。

以下のコードはパネルを開閉した時の処理は省略しています。
開いた時に `lockScroll()` を、閉じた時に `unlockScroll()` を呼べば良いです。

```html:コンポーネントを残してロックする場合の例
<script lang="ts">
  import { disableBodyScroll, clearAllBodyScrollLocks } from 'body-scroll-lock'
  import MainMenu from './components/MainMenu.svelte'

  let menu: MainMenu // コンポーネントのインスタンス

  /** スクロールロックする */
  function lockScroll(): void {
    disableBodyScroll(menu) // 引数をコンポーネントのインスタンスにする
  }

  /** スクロールロックを解除する */
  function unlockScroll(): void {
    clearAllBodyScrollLocks()
  }
</script>

{#if menuIsOpen}
  <MainMenu
    bind:this="{menu}"
    on:close="{() => { menuIsOpen = false }}"
  />
{/if}
```

### iOS での問題

body-scroll-lock (4.0.0-beta) を使った時に、iOS (16.0.2) で問題がありました。
`disableBodyScroll()` をすると以下のようになったのです。
![07_scroll_lock_ios.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339253/d622d3b1-8892-8393-77b8-26f62b260876.png)
[`disableBodyScroll()` のソースコード](https://github.com/willmcpo/body-scroll-lock/blob/157d235d7c09350f92632ead7a60aacc3c88cd93/src/bodyScrollLock.js#L179) を見てみると、iOS デバイスかどうかで処理を分けているようです。

幸い、「第1回での問題点」は iOS では起きていなかったので、iOS の場合は body-scroll-lock を使わないようにしました。

## 改善後のコード

改善後のコードは以下のようになりました ([→ソース全文](https://github.com/satamame/pscv/blob/qiita.07/src/App.svelte))。
`isIOS` は [別途定義](https://github.com/satamame/pscv/blob/qiita.07/src/lib/env.ts#L6) して import しています。

```html:src/App.svelte (抜粋)
<script lang="ts">
  /* import (略) */
  /* 変数宣言 (略) */

  const rootElement = document.documentElement
  let viewerTop = HEADER_HEIGHT

  // スクロールロックを解除した直後に実行するコールバック
  let onUnlockScroll: (() => void) | null = null

  /* 中略 */

  $: isModal = tocIsOpen || menuIsOpen || dataIsOpen || aboutIsOpen

  // モーダル状態が変わった時の処理
  $: if (viewer) {
    if (isModal) {
      lockScroll() // スクロールロックする
    } else {
      unlockScroll() // スクロールロックを解除する
    }
  }

  /** スクロールロックする */
  function lockScroll(): void {
    // iOS の場合は style を書き換える方法を使う
    if (isIOS) {
      const scrollTop = rootElement.scrollTop
      rootElement.style.position = 'fixed'
      viewerTop = HEADER_HEIGHT - scrollTop
    }
  }
  // iOS 以外では body-scroll-lock を使う
  $: if (!isIOS) {
    if (toc) {
      disableBodyScroll(toc)
    } else if (menu) {
      disableBodyScroll(menu)
    } else if (data) {
      disableBodyScroll(data)
    } else if (about) {
      disableBodyScroll(about)
    }
  }

  /** スクロールロックを解除する */
  function unlockScroll(): void {
    if (isIOS) {
      // iOS の場合は style を書き換える方法を使う
      const scrollTop = HEADER_HEIGHT - viewerTop
      viewerTop = HEADER_HEIGHT
      rootElement.style.position = 'static'
      rootElement.scrollTop = scrollTop
    } else {
      // iOS 以外では body-scroll-lock を使う
      clearAllBodyScrollLocks()
    }
    // コールバックがあれば実行する
    if (onUnlockScroll) {
      setTimeout(() => {
        onUnlockScroll()
        onUnlockScroll = null
      }, 0)
    }
  }
</script>

<Viewer
  bind:this="{viewer}"
  bind:psc
  bind:top="{viewerTop}"
/>

<!-- 中略 -->

{#if menuIsOpen}
  <MainMenu
    bind:this="{menu}"
    on:close="{() => { menuIsOpen = false }}"
  />
{/if}

<!-- 他のパネル (略) -->
```

今のところ複数のパネルを同時に開くことはありませんが、iOS 以外でスクロールロックする時にどのパネルを有効にするかの判定順は、[Android の Back ボタンで閉じるパネルを決める時](https://github.com/satamame/pscv/blob/qiita.07/src/lib/back.ts#L23) と同じにしてあります。
そして、[リアクティブ宣言](https://svelte.jp/docs#component-format-script-3-$-marks-a-statement-as-reactive) を使って `lockScroll()` の外で処理しています。

第1回ではスクロールロック時に `main` 要素の `top` を動かすことで見た目の位置をロック前に合わせていました。
その後 `main` 要素を廃止したので、ここでは [`Viewer` コンポーネント](https://github.com/satamame/pscv/blob/qiita.07/src/components/Viewer.svelte) に `top` プロパティを持たせて同じことをしています。

### onUnlockScroll

上記のコードで定義している `onUnlockScroll` は、スクロールロックを解除した直後に実行するコールバックです。
目次から見出しにジャンプする処理など、パネルを閉じてスクロールロックを解除してから実行したい処理は、このコールバックにセットすることにします。

# 目次を作る

## 目次のデータ

目次のデータは台本の見出し行 (題名, 登場人物見出し, 柱) の情報を抽出したものです。
この情報を `PSc` クラスのプロパティにしました。
`PSc` クラスについては [「<第5回> データを読込む」](https://qiita.com/satamame/items/529b016e95f6d412dbad) を参照してください。

`PSc` クラスに `headlines` というプロパティを作って、コンストラクタで値を生成するようにしました ([→ソースコード](https://github.com/satamame/pscv/blob/qiita.07/src/lib/psc.ts#L33))。
これで、今まで通り台本データを読込むだけで目次データを使えます。

あとは目次である `Toc` コンポーネントにもプロパティを作って、`App` コンポーネントの `psc` プロパティをバインドしておけば、その情報を使えるようになります。

```html:src\App.svelte (抜粋)
{#if psc && tocIsOpen}
  <Toc
    bind:this="{toc}"
    bind:psc
    on:close="{() => { tocIsOpen = false }}"
  />
{/if}
```

```html:src\components\Toc.svelte (抜粋)
<script lang="ts">
  /* 中略 */
  export let psc: PSc
</script>

<div class="panel" class:gone>
  <!-- 中略 -->
  <ul>
    {#each psc.headlines as item}
      <li>{item.text}</li>
    {/each}
  </ul>
</div>
```

![07_toc.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339253/bcb0a488-d677-2e89-99b2-5e7970e2ca15.png)

## 見出しにジャンプする

目次項目を選択した時に見出しにジャンプするには、最終的にはドキュメントのルート要素をスクロールさせる必要があります。
どこへスクロールさせるかは見出し要素の位置によるので、`Viewer` コンポーネントで計算します。
つまりこの処理には、`App` とその子である `Toc`, `Viewer` という3つのコンポーネントが関わります。

```:コンポーネントの親子関係
App
 ┣━ Toc
 ┗━ Viewer
```

1. `Toc` がイベントを `dispatch` して自分を閉じる。
1. `App` がスクロールの処理を `onUnlockScroll` にセットする。
1. その処理の中で `Viewer` にスクロール位置を問い合わせる。

### Toc の実装

([→ソース全文](https://github.com/satamame/pscv/blob/qiita.07/src/components/Toc.svelte))

```html:src/components/Toc.svelte (抜粋)
<script lang="ts">
  /* 中略 */

  /** index 番目の見出し行にスクロールする */
  function goToHeadline(index: number) {
    dispatch('goToHeadline', { index })
    // Android の場合は履歴を遡りながらスクロールする必要がある
    // close() 後に履歴を遡るとスクロールがリセットされてしまう
    if (isAndroid) {
      back()
    } else {
      close()
    }
  }
</script>

<div class="panel" class:gone>
  <!-- 中略 -->
  <ul>
    {#each psc.headlines as item, index}
      <li on:click="{() => { goToHeadline(index) }}">
        {item.text}
      </li>
    {/each}
  </ul>
</div>
```

`on:click` で `goToHeadline` というイベントを `dispatch` しています。
引数には `index` (何番目の見出しか) を渡しています。

`dispatch` した後に Android の場合は `close()` ではなく `back()` をしていますが、これはスクロールをリセットされてしまうのを防ぐためです。
`close()` からも `back()` が呼ばれていて「履歴があれば遡る」という動きをするので、先に `back()` を呼ぶことで、閉じた後に履歴を遡らない (スクロールをリセットされない) ようにしています ([<第4回> Back ボタン対応](https://qiita.com/satamame/items/ff499e66fe55aa0fd551) を参照)。

### Viewer の実装

([→ソース全文](https://github.com/satamame/pscv/blob/qiita.07/src/components/Viewer.svelte))

```html:src/components/Viewer.svelte (抜粋)
<script lang="ts">
  /* 中略 */

  /** 行の Y 座標を返す */
  export function getLineY(index: number): number {
    if (index == 0) {
      return 0
    }
    /* 行には必ず <p> があるので children[0] を対象とする */
    const target = container.children[index].children[0] as HTMLElement
    const marginTop = window.getComputedStyle(target).marginTop
    const y = target.offsetTop - parseFloat(marginTop)
    return y
  }

  /** 見出し行の Y 座標を返す */
  export function getHeadlineY(index: number): number {
    return getLineY(psc.headlines[index].lineIndex)
  }

  /* 中略 */
</script>
```

### App の実装

([→ソース全文](https://github.com/satamame/pscv/blob/qiita.07/src/App.svelte))

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

  /** スクロールロック解除後に見出し行に移動するという設定をする */
  function goToHeadline(index: number): void {
    onUnlockScroll = () => {
      const y = viewer.getHeadlineY(index)
      rootElement.scrollTop = y
    }
  }
</script>

{#if psc && tocIsOpen}
  <Toc
    bind:this="{toc}"
    bind:psc
    on:close="{() => { tocIsOpen = false }}"
    on:goToHeadline="{(e) => { goToHeadline(e.detail.index) }}"
  />
{/if}
```

## 目次で現在のシーンを強調する

目次を開いた時に、閲覧中のシーンの項目を強調するようにします。
![07_highlight_toc_item.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339253/49b62acd-8425-d1fe-85ab-ae65e31fe2c5.png)

`Toc` コンポーネントに `current` というプロパティを持たせて、この値に相当する項目 (\<li> 要素) に、強調表示のためのクラスを指定しています。

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

<div class="panel" class:gone>
  <!-- 中略 -->
  <ul>
    {#each psc.headlines as item, index}
      <li
        class:current="{ index == current }"
        on:click="{() => { goToHeadline(index) }}"
      >
        {item.text}
      </li>
    {/each}
  </ul>
</div>

<style>
  .current {
    font-weight: bold;
  }
</style>
```

これを開く `App` コンポーネント側では、現在のスクロール位置から強調する見出し (`current` に渡す値) を判定する必要があります。
この判定は2段階になっていて、まず画面の一番上に表示されている行の番号を `Viewer` に判定してもらって、次にその行が属する見出しの番号を `PSc` クラスに判定してもらいます。

```html:src/App.svelte (抜粋)
<script lang="ts">
  /* 中略 */
  let currentTocIndex = 0

  /** スクロール位置の見出しを判定してから目次を開く */
  function openToc(): void {
    const currentLineIndex = viewer.getLineIndexAtY(rootElement.scrollTop)
    currentTocIndex = psc.headlineForLine(currentLineIndex)
    tocIsOpen = true
  }
</script>

<!-- 中略 -->

{#if psc && tocIsOpen}
  <Toc
    bind:this="{toc}"
    bind:psc
    bind:current="{currentTocIndex}"
    on:close="{() => { tocIsOpen = false }}"
    on:goToHeadline="{(e) => { goToHeadline(e.detail.index) }}"
  />
{/if}
```

判定用の2つの関数は PWA や Svelte とあまり関係ないので、ソースのリンクを貼るにとどめます。どちらも [バイナリサーチ](https://ja.wikipedia.org/wiki/%E4%BA%8C%E5%88%86%E6%8E%A2%E7%B4%A2) を使っています。

- [Viewer.getLineIndexAtY() のソース](https://github.com/satamame/pscv/blob/qiita.07/src/components/Viewer.svelte#L51)
- [PSc.headlineForLine() のソース](https://github.com/satamame/pscv/blob/qiita.07/src/lib/psc.ts#L93)

`Viewer.getLineIndexAtY()` には動作確認用のコードがあります。
[App.svelte](https://github.com/satamame/pscv/blob/qiita.07/src/App.svelte) と [Viewer.svelte](https://github.com/satamame/pscv/blob/qiita.07/src/components/Viewer.svelte) の `/* DEBUG */` と書かれているところのコメントアウトを解除すると、スクロールを監視して常に画面の一番上の行 (と判定された行) が赤くなるようになります。
![07_debug.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339253/d974740c-d6d3-3f3f-5c07-11a00a6cdf4b.png)