PWA で作った台本ビューア を Svelte で作り直すことにしたので、やったことをメモしていきます。
- 前回 : <第6回> 更新の動作の改善
- 次回 : <第8回> 台本をローカル DB に保存
- シリーズ記事一覧
<第7回> 目次によるジャンプ
今回のテーマは、読込んだ台本データの目次を作ってそこから各見出しにジャンプすることです。
しかしその前に、スクロールロックの改善について説明します。
スクロールロックについては 「<第1回> 基本のコンポーネントを作る」 の 「スクロールを固定する」 という章で説明しましたが、少々問題があって最近その改善をしました。
また、目次から見出しへのジャンプにもスクロールロックの挙動が関わりますので、最初に説明しようと思います。
今回の概要
- パネルを開いた時の、ルート要素のスクロールロックを改善します。
- 読込んだ台本データから目次を作ります。
- 目次から選んだ見出しにジャンプする仕組みを作ります。
- 目次を開いた時に、閲覧中のシーンの項目を強調するようにします。
今回使うソースコードは GitHub のこちらのコミット にあります。
スクロールロックの改善
ここでいう「スクロールロック」とは、メニューなどのパネルを開いた時にドキュメントをスクロールできなくすることです。
パネルをモーダルにする時は下図のようにオーバーレイ (半透明の板) を敷いてパネル以外は操作できなくしますが、なぜかスクロールは出来てしまうので、それを止めます。
第1回での問題点
第1回でやったスクロールロックの問題点として、ロックを解除する時 (パネルを閉じた時) に別の位置にスクロールしてしまうケースがありました。
ドキュメントのルート要素の position
を fixed
から static
に戻して、すぐにスクロール位置も元に戻していたのですが、少し待ってからスクロール位置を戻さないといけなかったようです。
しかし、少し待ってから (具体的には setTimeout
のコールバックとして) スクロール位置を戻すようにすると、今度は position
が static
になった瞬間が見えるようになりました。
画面全体がちらつくような感じです。
body-scroll-lock の導入
ちらつきを抑えようと色々やってみたのですが上手く行かず、body-scroll-lock というライブラリのお世話になることにしました。
使い方は body-scroll-lock の GitHub に書いてあります。
フレームワークごとに使い方の例がありますが、Svelte はありません。
Svelte では targetElement
引数をコンポーネントのインスタンスにすれば動きました。
以下のコードはパネルを開閉した時の処理は省略しています。
開いた時に lockScroll()
を、閉じた時に unlockScroll()
を呼べば良いです。
<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()
をすると以下のようになったのです。
disableBodyScroll()
のソースコード を見てみると、iOS デバイスかどうかで処理を分けているようです。
幸い、「第1回での問題点」は iOS では起きていなかったので、iOS の場合は body-scroll-lock を使わないようにしました。
改善後のコード
改善後のコードは以下のようになりました (→ソース全文)。
isIOS
は 別途定義 して import しています。
<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 ボタンで閉じるパネルを決める時 と同じにしてあります。
そして、リアクティブ宣言 を使って lockScroll()
の外で処理しています。
第1回ではスクロールロック時に main
要素の top
を動かすことで見た目の位置をロック前に合わせていました。
その後 main
要素を廃止したので、ここでは Viewer
コンポーネント に top
プロパティを持たせて同じことをしています。
onUnlockScroll
上記のコードで定義している onUnlockScroll
は、スクロールロックを解除した直後に実行するコールバックです。
目次から見出しにジャンプする処理など、パネルを閉じてスクロールロックを解除してから実行したい処理は、このコールバックにセットすることにします。
目次を作る
目次のデータ
目次のデータは台本の見出し行 (題名, 登場人物見出し, 柱) の情報を抽出したものです。
この情報を PSc
クラスのプロパティにしました。
PSc
クラスについては 「<第5回> データを読込む」 を参照してください。
PSc
クラスに headlines
というプロパティを作って、コンストラクタで値を生成するようにしました (→ソースコード)。
これで、今まで通り台本データを読込むだけで目次データを使えます。
あとは目次である Toc
コンポーネントにもプロパティを作って、App
コンポーネントの psc
プロパティをバインドしておけば、その情報を使えるようになります。
{#if psc && tocIsOpen}
<Toc
bind:this="{toc}"
bind:psc
on:close="{() => { tocIsOpen = false }}"
/>
{/if}
<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>
見出しにジャンプする
目次項目を選択した時に見出しにジャンプするには、最終的にはドキュメントのルート要素をスクロールさせる必要があります。
どこへスクロールさせるかは見出し要素の位置によるので、Viewer
コンポーネントで計算します。
つまりこの処理には、App
とその子である Toc
, Viewer
という3つのコンポーネントが関わります。
App
┣━ Toc
┗━ Viewer
-
Toc
がイベントをdispatch
して自分を閉じる。 -
App
がスクロールの処理をonUnlockScroll
にセットする。 - その処理の中で
Viewer
にスクロール位置を問い合わせる。
Toc の実装
(→ソース全文)
<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 ボタン対応 を参照)。
Viewer の実装
(→ソース全文)
<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 の実装
(→ソース全文)
<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}
目次で現在のシーンを強調する
目次を開いた時に、閲覧中のシーンの項目を強調するようにします。
Toc
コンポーネントに current
というプロパティを持たせて、この値に相当する項目 (<li> 要素) に、強調表示のためのクラスを指定しています。
<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
クラスに判定してもらいます。
<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 とあまり関係ないので、ソースのリンクを貼るにとどめます。どちらも バイナリサーチ を使っています。
Viewer.getLineIndexAtY()
には動作確認用のコードがあります。
App.svelte と Viewer.svelte の /* DEBUG */
と書かれているところのコメントアウトを解除すると、スクロールを監視して常に画面の一番上の行 (と判定された行) が赤くなるようになります。