PWA で作った台本ビューア を Svelte で作り直すことにしたので、やったことをメモしていきます。
使っている技術
- Svelte (チュートリアル終えたくらい)
- PWA (簡単なアプリを作れるくらい)
- TypeScript (初心者)
記事リンク
- 次回 : <第2回> PWA にする
- シリーズ記事一覧
<第1回> 基本のコンポーネントを作る
今回の概要
- 今回はプロジェクトを作るところからです。
- まだ PWA にはしていません。
- 基本的な UI としてヘッダとドロワーをコンポーネントにするところまでです。
今回使うソースコードは GitHub のこちらのコミット にあります。
元の台本ビューアについて
元になっている台本ビューアのソースコードは GitHub のこちらのコミット にあります。
実際に動くものは以下にあります。
-
satamame.github.io/pscv/
- Svelte 版ができるまではここで公開します。
-
satamame.ddns.me:5380/pscv002/
- 継続的に公開しますが、たまにメンテ中になります。
機能
- シーン目次
- 台本内検索
- 文字サイズ変更
- 横書き・縦書き
プロジェクト作成
Windows 10 で、node, npm, yarn がインストールされている前提です。
私の環境では以下のバージョンでした。
- node : 16.17.0
- npm : 8.18.0
- yarn : 1.22.19
プロジェクトは Vite で作って、TypeScript も使える設定にしました。
> yarn create vite pscv --template svelte-ts
次にプロジェクトフォルダ (この場合は pscv) に入ってパッケージのインストールをしました。
> cd pscv
> yarn
これで node_modules フォルダや設定ファイルが出来ます。
以下のコマンドで開発サーバが起動します。
> yarn dev
TypeScript を使う場合の変更
tsconfig.json に以下の設定を追加しました。
この設定をしないと、コンポーネントのファイル名などを大文字始まりにした時にエラーになるようです。
{
"compilerOptions": {
(中略)
"forceConsistentCasingInFileNames": false <- これを追加
},
}
マウント先を変える
インストール直後は、index.html の body
内の div
以下にアプリがマウントされるようになっています。
これを body
直下にマウントするようにしました。
理由は、これから作る main
要素や Header
コンポーネントを body
直下に置きたいからです。
もし div
をすげ替えるなど SPA 的なことをするなら、この構造は変えない方が良いです。
やり方は、まず index.html の body
内の div
を削除します (→ソース全文)。
<body>
<div id="app"></div> <!-- この行を削除 -->
<script type="module" src="/src/main.ts"></script>
</body>
そして src/main.ts の target
を変更します (→ソース全文)。
const app = new App({
target: document.getElementById('app') // 変更前
target: document.body // 変更後
})
不都合な操作を封じる
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
コンポーネントとしました。
ヘッダなのでスクロールで動かないように position: fixed;
にします。
それ以外のポイントとして (1) 画像のパスを正しく設定する, (2) ボタンのイベントを親コンポーネントに渡す, があります。
画像のパスを正しく設定する
画像は以下のパスに配置しました。
※ Google の Material Icons からダウンロードして使っています。
pscv
└─public
└─ui_icon
├─menu_open_black_24dp.svg
└─toc_black_24dp.svg
public フォルダ内のコンテンツは、ビルドするとアプリのルートにコピーされます。
Header
コンポーネントでは画像のパスを以下のように指定しました (→ソース全文)。
<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
文が何をするかというと、パスをサーバルートからのパスに変換します (→参考)。
Vite のデフォルトではアプリのルートがサーバルートなのでこの変換は必要ありません。
アプリのルートを変える場合は vite.config.ts に base というコンフィグを追加します (そしてデプロイ時にその位置にデプロイします)。
export default defineConfig({
base: '/pscv/', // これを追加
plugins: [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) の方は以下のような感じです (→ソース全文)。
目次用の Toc
コンポーネントとメニュー用の MainMenu
コンポーネントはまだありませんが、プロパティ xxIsOpen
の true
/false
で表示/非表示を切り替える仕組みを作っておきます。
<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))。
何が違うかというと、(2) にするとスマホのブラウザでスクロールした時に、アドレスバー等の UI が隠れて画面を広く使えるようになります。
PWA としてインストールして使う場合には関係ありません。
とりあえず適当な内容を入れて Header
より先に (Header より背面になるように) 書きます。
<main>
<適当な内容 />
</main>
<Header
.....
スタイルは以下のようにしました (同じ App.svelte の下の方にあります)。
<style>
main {
position: absolute;
top: 48px;
left: 0;
right: 0;
padding: 8px 12px 12px 18px;
}
</style>
適当な内容を表示するコンポーネント
開発中に適当な内容を表示するためのコンポーネントも作ってみました。
src/components/LoremIpsum.svelte にあるので内容はそちらをご覧ください。
たとえば以下のようにすると、main
の中に適当な div
を4個表示します。
<script lang="ts">
/* 中略 */
import LoremIpsum from './components/LoremIpsum.svelte'
</script>
<main>
<LoremIpsum blockCount="{4}" />
</main>
ドロワー
ヘッダの左右のボタンを押して表示する Toc
と MainMenu
はどちらもドロワーとして作りました。
作りは同じですので Toc
コンポーネントだけ見ていきます。
Toc
コンポーネントはドロワー本体のほかにオーバーレイも表示します。
これもコンポーネントとして作りました。
オーバーレイ
Overlay
コンポーネントは、モーダルな UI を表示する時に使えるような、単に全画面を覆うだけの div
要素にしました。
click
イベントを親にフォワードするよう on: ディレクティブをつけてあります。
<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
コンポーネントの内容は以下のような感じです。
ドロワー部分のスタイル等は ソース全文 をご覧ください。
<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
イベントを処理するようにしました (→ソース全文)。
{#if tocIsOpen}
<Toc on:close="{() => { tocIsOpen = false }}" />
{/if}
{#if menuIsOpen}
<MainMenu on:close="{() => { menuIsOpen = false }}" />
{/if}
これで一応ドロワーが出たり消えたりするのですが、アニメーションをつけたいところです。
ドロワーのアニメーション
Svelte の transition でドロワーとオーバーレイを同時にアニメーションする方法が分からなかったので、スクリプトで制御するようにしました。
前節のソースコードに余計な class
や bind
があったのはそのためです。
2022/10/11 追記
第3回 の「About を開くまでの部分」で、ドロワーのアニメーションの実装をもっとすっきりした書き方に改善しました。
ドロワー本体は panel
というクラスです。これと、「閉じる時にどう変化するか」を表す panel-gone
というクラスを用意しました。
<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
クラスを取消した時) はその逆の動きをするようになります。
オーバーレイは、移動はせずに透明度だけ変えれば良いです。
<style>
/* 中略 */
.overlay {
transition: opacity 0.2s;
}
.overlay-gone {
opacity: 0;
}
/* 中略 */
</style>
アニメーションのタイミング
次に、これらのクラスを適用する (または取消す) タイミングについてです。
まず、コンポーネントがマウントされた時に「閉じた状態から開いた状態へ」アニメーションして欲しいので、panel-gone
と overlay-gone
を適用した状態から始めます。
⇒ 前節「ドロワーを閉じる仕組み」のソースコード参照
そして開くアニメーションのために onMount() で「閉じた状態のクラス」を取消す処理を設定します。
この時レンダリングを待つ必要があるらしいので、setTimeOut()
で遅延させるようにしました (Svelte の tick() では上手くいきませんでした)。
<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
プロパティ) を立てるようにしました。
<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
コンポーネントも、やっていることは同じです (→ソース全文)。
スクロールを固定する
ドロワー等のモーダルな UI を開いている時は、台本をスクロールできないようにしたいと思いました。
オーバーレイに色を入れているのも、それを想定してのことです。
ここでのスクロールを固定する方法は問題があったので、第7回 で改善しています。
スクロールを固定する機能は App
コンポーネントの中で完結しますが、HTML ルート要素のスタイルは src/app.css の方で変えておくのが良いです。
:root {
width: 100%;
/* 中略 */
}
これは、スクロールを固定するのにルート要素を position: fixed;
にした時、ルート要素の幅が変わらないようにするためです。
position: fixed;
にするとスクロール位置もリセットされるので、その分だけ中身を動かして移動量を相殺しないと見た目が不自然になります。
以下のようなコードを書くことで、モーダルになったら HTML ルート要素のスクロールを固定して中身の位置を調整し、モーダルを解除したら元の状態を復元するようにしました。
<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
はモーダル状態を表し、リアクティブ宣言 しているので Toc
か MainMenu
が開いたり閉じたりするたびに更新されます。
その下の if
文もリアクティブ宣言されているので、isModal
の値が変わるたびに実行されます。
モーダルになった時は上図の右側の状態になるようにして、モーダルが解除されたら左側の状態に戻るようにすれば出来上がりです。
ただし、今後は縦書き表示に対応するので、スクロール周りはいろいろと変更が入ると思います。
参考リンク
- Introduction / Basics • Svelte Tutorial
- Docs • Svelte
- vite/packages/create-vite at main · vitejs/vite
- Static Asset Handling | Vite
- Shared Options | Vite
- Material Symbols and Icons - Google Fonts