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

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

- 前回 : [<第8回> 台本をローカル DB に保存](https://qiita.com/satamame/items/2e9794d6b084b86b2044)
- 次回 : [<第10回> Svelte, TS, Dexie の更新](https://qiita.com/satamame/items/bb0e3acd3e2b47ffa3a2)
- [シリーズ記事一覧](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)

# <第9回> 台本一覧を D&D で並べ替え

今回のテーマは、「台本データ」画面のリストをドラッグ&ドロップで並べ替えられるようにすることです。
[標準のドラッグ&ドロップ API](https://developer.mozilla.org/ja/docs/Web/API/HTML_Drag_and_Drop_API) や [svelte-dnd-action](https://www.npmjs.com/package/svelte-dnd-action) を使うことも検討したのですが、上手く実装できないことがあったので独自に `DndList` というコンポーネントを作りました。

**今回の概要**

- 「台本データ」画面の並べ替えの UI でやりたかったこと。
- 独自実装した `DndList` コンポーネントの説明。

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

# 並べ替えの UI でやりたかったこと

## ドラッグハンドル

セル内のドラッグハンドルを触ることでドラッグを開始するようにします。
これ自体は svelte-dnd-action でも実現可能です ([→サンプルコード](https://svelte.dev/repl/4949485c5a8f46e7bdbeb73ed565a9c7?version=3.59.1))。
![09_drag_handle.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339253/069a017c-a7c6-1d75-06d8-2be3ed90e3c7.png)

## ドラッグ中の表示

ドラッグ中の表示は以下のようにします。
半透明でポインタに合わせて移動するのが「ゴースト」です。
グレーで移動先をプレビューしているセルが「シャドウ」です。
![09_ghost_and_shadow.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339253/7f052178-8ff5-81ac-3885-0b79f680c93a.png)

## ゴーストの移動をリスト内に制限する

ドラッグ中はポインタを如何に動かしても、ゴーストがリスト内でしか動かないようにします。
これは、うっかりリストから少し外れてドロップしても、その時点での移動をキャンセルしたくないからです。
特にスクロールを伴う移動では、うっかりリスト外にドロップすることはストレスになると思います。

## セルの区切り線を正しく表示する

セルの区切り線は、セルに `border-bottom` を設定して表示しています。
ただし一番下のセルについては、リストの下端よりも上に配置される場合以外は区切り線を表示しません。

このルールをドラッグ中も保つようにして、ドラッグ中も区切り線がない、または余計に表示されるといったことがないようにします。

# 標準の API 等でできなかったこと

やりたかったことのうち、以下のことが標準のドラッグ&ドロップ API や svelte-dnd-action ではできませんでした。
※やり方を調べるのが大変そうで諦めたことも、「できなかったこと」としています。

- ゴーストの移動をリスト内に制限する
- セルの区切り線を正しく表示する
- ドラッグ中はリスト以外はスクロールしない

3つめの「ドラッグ中はリスト以外はスクロールしない」は、「台本データ」画面では考慮する必要はないのですが、コンポーネントとしては考慮するようにしました。
考慮しないとどうなるかというと、たとえば svelte-dnd-action では、スマホのブラウザでタッチ操作でドラッグした時にドラッグとページスクロールが同時に起こります。

# DndList コンポーネント

独自実装した `DndList` コンポーネントについて説明します。
ソースコードは [こちら](https://github.com/satamame/pscv/blob/qiita.09/src/components/UI/DndList.svelte) をご覧ください。

## 機能の概要

- コンポーネントプロパティとして `items` を受け取ります。
    - `items` はオブジェクトのリストで、各要素に `id` プロパティが必須です。
- `items` はドラッグによって並び順が変わり、親で `bind` した変数にも反映されます。
- ドラッグ中は、ドロップ後の並び順のプレビューがリアルタイムに表示されます。
- ドラッグ中にゴーストがリストの表示範囲の上端または下端に達した時、可能ならスクロールします。
- ドラッグ開始時に `disableScroll` イベントを発火します。
    - イベントを受け取った親は、必要ならスクロールロックの処理をします。
- ドラッグ終了時に `enableScroll` イベントを発火します。
    - イベントを受け取った親は、必要ならスクロールロックを解除します。
- ドラッグ終了時に `sorted` イベントを発火します。
    - イベントを受け取った親は、`items` の並び順に応じて必要な処理をします。

## 使い方

「台本データ」画面で `DndList` コンポーネントをどのように使っているか、簡単に説明します。
「台本データ」画面のソースコード全文は [こちら](https://github.com/satamame/pscv/blob/qiita.09/src/components/DataList.svelte) をご覧ください。

### items を用意する

前回は [Dexie.js の liveQuery](https://qiita.com/satamame/items/2e9794d6b084b86b2044#livequery-%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6) の結果を [そのまま表示](https://qiita.com/satamame/items/2e9794d6b084b86b2044#%E4%B8%80%E8%A6%A7%E8%A1%A8%E7%A4%BA%E7%94%A8%E3%81%AE%E5%8F%B0%E6%9C%AC%E3%83%87%E3%83%BC%E3%82%BF%E3%83%91%E3%83%8D%E3%83%AB) していました。
liveQuery は DB の内容を映す窓のようなものなので、今回のように並べ替えの対象とすることはできません。
そこで、ただの配列として `items` を用意して、これを liveQuery に対してリアクティブにすることで DB の変更が反映されるようにします。

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

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

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

### セル用のコンポーネントを用意する

まず、セル用のコンポーネントをどのように呼び出すか見てください。
以下のコードの `DataCell` がセル用のコンポーネントです。

```html:DataList.svelte (抜粋)
    {#if items}
      <DndList
        bind:items="{items}"
        let:item="{scIndexItem}"
        let:cellId
        on:sorted="{listSorted}"
      >
        <DataCell
          item="{scIndexItem}"
          cellId="{cellId}"
          on:showPSc="{e => showPSc(e.detail.scriptId)}"
          on:showInfo="{e => showInfo(e.detail.id)}"
        />
      </DndList>
    {/if}
```

`DataCell` は実際には複数あって、`DndList` 内のループの中の各 `slot` に対して生成されます。
セルごとに必要な情報は [`slot` のプロパティ](https://svelte.jp/docs#template-syntax-slot-slot-key-value) になっています。

```html:DndList.svelte (抜粋)
<div class="scroll-box" bind:this="{scrollBox}">
  <div bind:this="{cellsRow}">
    {#each items as item (item.id)}
      <slot item="{item}" cellId="cell{item.id}"></slot>
    {/each}
  </div>
</div>
```

ですので、それを受け取るのに `DataCell` コンポーネントにも `item` と `cellId` というプロパティを持たせています。
`DataCell` コンポーネントのソース全文は [こちら](https://github.com/satamame/pscv/blob/qiita.09/src/components/DataCell.svelte) をご覧ください。

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

  import type { ScriptIndex } from '../lib/db'
  import type { DndCellItem } from './UI/DndList.svelte'

  /* 中略 */

  // コンポーネントプロパティ
  export let item: DndCellItem
  export let cellId: string

  let scIndex = item as ScriptIndex
</script>

<div
  id="{cellId}"
  class="cell bottom-line"
>
  <!-- 中略 -->
  <div class="label">{scIndex.name}</div>
  <!-- 中略 -->
  <div id="dragHandle" class="icon drag-handle">
    <img src="{dragHandle}" alt="drag" />
  </div>
</div>

<style>
  .cell:last-child {
    border-bottom-width: 0;
  }
  .bottom-line:last-child {
    border-bottom-width: 1px;
  }
</style>
```

`item` プロパティの型は、親コンポーネントでエラーが出ないように DndList.svelte で定義されている `DndCellItem` にしますが、実際の中身は `ScriptIndex` を期待するので、キャストして別の変数 (scIndex) に入れています。
`DndCellItem` には id プロパティが必要ですが、`ScriptIndex` にも id があるので問題ありません。

`cellId` はこのセルの (HTML 要素としての) id 属性に設定しておく必要があります。

セル内でドラッグハンドルとして使いたい要素には `dragHandle` という id を設定します。
イベントリスナーは `DndList` コンポーネントが設定してくれます。

`cell` クラスと `bottom-line` クラスを定義することで、`bottom-line` クラスだった場合だけ最後のセルに区切り線を表示するようにします。
`bottom-line` クラスのオン・オフは `DndList` コンポーネントによって行われます。

### イベントハンドラの実装

`DndList` から親コンポーネントが受け取るイベントは、`disableScroll`, `enableScroll`, `sorted` の3つです。

#### disableScroll イベント

ドラッグ開始時に発火します。
スマホのタッチ操作でドラッグとスクロールが同時に起きないように、このイベントを受け取った親はスクロールロックの処理をします。
台本ビューアでは [モーダルな UI を開いている時はページのスクロールをロックしている](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) ので、このイベントを処理する必要はありません。

#### enableScroll イベント

ドラッグ終了時に発火します。
`disableScroll` と対になっていて、親がスクロールロックを解除するためのものです。

#### sorted イベント

ドラッグ終了時に発火します。
ドラッグが終了した時点で `items` の並べ替えは `bind` によって反映されますが、それ以外の処理をしたい場合にハンドラを実装します。

台本ビューアでは `items` の元になっている DB の scriptIndex テーブルを更新する必要があるため、その実装をしています。

```html:DataList.svelte (抜粋)
<script lang="ts">
  /* 中略 */
  function listSorted() {
    const ids = items.map(item => item.id)
    db.sortByIds(ids)
  }
</script>
```

`sorted` が発火した時点で `items` は並べ替えられているので、`items` から `id` だけを取り出して、その配列を [`db.sortByIds` 関数](https://github.com/satamame/pscv/blob/qiita.09/src/lib/db.ts#L70) に渡しています。

## 制限

- このコンポーネントは、リストの並びが「縦」である想定となっています。
- ドラッグをキャンセルする処理はありません。

# まとめ

今回 作成した `DndList` コンポーネントは、「台本データ」画面以外でも使えるようにはしましたが、あくまで私が使いたい機能の実装であって、他のプロジェクトで使えるほどには汎用的ではありません。

ただ、一部の変数や関数を `export` することでさらに汎用性を高めることも可能かとは思います。
たとえばゴーストやシャドウのスタイル、セルの区切り線の扱い等です。

前の章の「制限」が許容できるのであれば、他のプロジェクトで使えるように改造してみるのも良いかと思っています。

# 参考リンク

- [HTML ドラッグ & ドロップ API - Web API | MDN](https://developer.mozilla.org/ja/docs/Web/API/HTML_Drag_and_Drop_API)
- [svelte-dnd-action - npm](https://www.npmjs.com/package/svelte-dnd-action)
- [Docs • Svelte &lt;slot key={value}&gt;](https://svelte.jp/docs#template-syntax-slot-slot-key-value)