Svelte でドラッグ可能なカードの作成


# はじめに

Svelte の勉強として [REPL] でドラッグ可能なカードを作成してみました。最初はシンプルなものだったのですが、使い勝手を上げたいといろいろいじってみたところ、いろんな機能を試すことができました。
例：

- Context を使って領域毎に z-index(重なり順) を管理
- `$` によるリアクティブな位置修正
- `<slot>` に対するスタイルの適用

# 実装ファイル一覧

|ファイル名|説明|
|---|---|
|App.svelte|アプリ本体。|
|Card.svelte|単純なカードコンポーネント。|
|DraggableArea.svelte|ドラッグ領域を表すコンポーネント。子要素の重なり順を管理。|
|Draggable.svelte|子要素をドラッグ可能にするコンポーネント。|

# 使い方

こんな感じを想定しています。

```html:サンプル
<!-- 領域その1 -->
<DraggableArea>
  <Draggable><Card color="red"/></Draggable>
  <Draggable><Card color="green"/></Draggable>
  <Draggable><Card color="blue"/></Draggable>
</DraggableArea>
<!-- 領域その2 -->
<DraggableArea>
  <Draggable>あいうえお</Draggable>
  <Draggable>かきくけこ</Draggable>
  <Draggable>さしすせそ</Draggable>
</DraggableArea>
```

# 実装

## Card.svelte

```html:Card.svelte
<script>
  export let color = "";
</script>

<div class="card" style:background-color={color} />

<style>
 .card {
    width: 100px;
    height: 50px;
    border-radius: 0.5rem;
    border-style: solid;
    border-width: 2px;
    border-color:rgba(0,0,0,0.1);
    box-sizing: border-box;
  }
</style>
```

## DraggableArea.svelte

```html:DraggableArea.svelte
<script>
  import {setContext} from 'svelte'
  
  let area;
  let items = [];
  setContext("area", {
    add: (item) => {
      items.push(item);
    },
    toFront: (item) => {
      items = items.filter(c => c !== item);
      Array.prototype.push.apply(items, [item]);
      items.forEach((item, i) => {
        item.style.zIndex = i + 1;
      });
    },
    remove: (item) => {
      items = items.filter(c => c !== item);
    },
    getWidth: () => {
      return area.clientWidth;
    },
    getHeight: () => {
      return area.clientHeight;
    }
  });
</script>

<div bind:this={area}>
  <slot/>
</div>

<style>
  div {
    position: relative;
  }
</style>
```
ポイント
- `items` は重なり順を管理するための変数で、`<DraggableArea>` 毎に用意されます。
- `setContext(..)` を使って子要素に、add、toFront、remove、getWidth、getHeight、関数を提供することで、子要素インスタンスから重なり具合を調整できるようになっています。
- `area` 変数を `<div>` にバインドすることで `area.clientWidth` という形で動的に横幅を取得できます。

## Draggable.svelte

```html:Draggable.svelte
<script>
  import {onMount, getContext} from 'svelte';
  export let x = 0;
  export let y = 0;
  
  let areaContext= getContext("area");
  let item;
  const position = { x: parseInt(x), y: parseInt(y) }; // 要素の位置
  const diff = {x: 0, y: 0}; // ドラッグ開始時の要素の位置とマウスの位置の差
  let isCatched = false;
  
  function handleMousemove(event) {
    if (isCatched) {
      position.x = parseFloat(event.clientX) - diff.x;
      position.y = parseFloat(event.clientY) - diff.y;
    }
  }

  // position が変更されたら領域内に収まるように調整する
  $: {
    position; // これを記載しないと position が変更されても stickInArea() は実行されない。(センスが悪い...)
    stickInArea()
  };
  
  function stickInArea() {
    if (!item) {
      return;
    }
    // エリアをはみ出さないように調整
    if (position.x < 0) {
      position.x = 0;
    } else if (position.x + item.clientWidth > areaContext.getWidth()) {
      position.x = areaContext.getWidth() - item.clientWidth;
    }
    if (position.y < 0) {
      position.y = 0;
    } else if (position.y + item.clientHeight > areaContext.getHeight()) {
      position.y = areaContext.getHeight() - item.clientHeight;
    }
  }
  
  function handleCatch(event) { 
    isCatched = true;
    diff.x = parseFloat(event.clientX) - parseFloat(position.x);
    diff.y = parseFloat(event.clientY) - parseFloat(position.y);
    areaContext.toFront(item);
  }

  function handleRelease(event) {
    isCatched = false;
  }

  onMount(() => {
    areaContext.add(item);
    return () => {
      areaContext.remove(item);
    };
  })
</script>

<svelte:window on:mousemove={handleMousemove} on:mouseup={handleRelease} on:resize={stickInArea}></svelte:window>

<div class="item" class:isCatched bind:this={item}
     style:top={position.y + "px"}
     style:left={position.x + "px"}
     on:mousedown|stopPropagation={handleCatch}
     >
  <slot/>
</div>

<style>
  .item {
    position: absolute;
    transition: box-shadow 0.3s, transform 0.3s;
    cursor: grab;
    user-select: none;
  }
  .item.isCatched > :global(*) {
    box-shadow: 0 0.25rem 1rem 0 rgba(0,0,0,0.5);
  }
  .item.isCatched {
    transform: scale(1.1);
    cursor: grabbing;
  }
</style>
```
ポイント
- `position` が変更されたら `stickInArea()` が実行され、`<DraggableArea>` 内に表示されるように調整されます。
   `$:` を使うことでリアクティブに実行されます。(React でいうところの ~~useState~~ useEffect みたいなやつです)
- ドラッグ中の子要素(`<slot/>`)のスタイルは `.item.isCatched > :global(*)` のように `:global` を使って指定できます。
- `<svelte:window>` を使うことで、ウィンドウのイベントリスナを設定できます。

## App.svelte

```html:App.svelte
<script>
  import DraggableArea from './DraggableArea.svelte';
  import Draggable from './Draggable.svelte';
  import Card from './Card.svelte';  
</script>
<div class="main">
  <DraggableArea>
    <Draggable x=50 y=50><Card color="rgba(255,0,0,0.9)"/></Draggable>
    <Draggable x=80 y=80><Card color="rgba(0,255,0,0.9)"/></Draggable>
    <Draggable x=110 y=110><Card color="rgba(0,0,255,0.9)"/></Draggable>
  </DraggableArea>
  <DraggableArea>
    <Draggable x=50 y=50>あいうえお</Draggable>
    <Draggable x=80 y=80>かきくけこ</Draggable>
    <Draggable x=110 y=110>さしすせそ</Draggable>
  </DraggableArea>
</div>

<style>
  .main {
    display:flex;
    flex-direction: row;
    height: 100%;
  }
  .main > :global(*) {
    width: 100%;
    background-color:rgba(100,100,120,0.2);
  }
  .main > :global(*:nth-child(odd)) {
    background-color:rgba(100,100,120,0.8);
  }
</style>
```

# 動作確認

[こちら](https://svelte.dev/repl/b5c2aae633cb4578ba6f6f335c57b102?version=3.55.1) で動作確認できます。
こんな感じで表示されるはずです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2773745/4d48be99-eba2-3f60-5bd3-bc411a568bef.png)

# まとめ

Svelte の [REPL] を使って簡単にドラッグ可能なカードを作成することができました。また、`DraggableArea` を導入することでドラッグ可能な範囲を制限することも実現できました。

[REPL] を使うと簡単に動作確認できるし、GitHub アカウントと連携すれば保存することもできるのでお勧めです。簡単にいろいろ試すことができておもしろいです。:smiley:

[REPL]:https://svelte.dev/repl