移動・リサイズ可能モーダルを作る

# 始めに
モーダルは今まで何回か作ったことがありましたが、移動やリサイズ可能なモーダルは作ったことがなく、たまたま作る機会がありましたので実装方法について記事にまとめました。

![Dec-26-2020 10-26-10.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/187768/0b8fa001-534f-f343-718f-9510486dd610.gif)

# 実装方法
## 移動の実装
移動ロジックは比較的単純で、ドラッグ中の移動量をみて、その分だけ位置を変更させます。
移動する際に枠をはみ出さないようにlodashのclampを使って最小値と最大値を指定します。

```vue:移動の実装
<template lang="pug">
.adjustable-modal(
  v-show="$props.isOpen"
  ref="elModal"
  :style="{\
    width: `${$data.width}px`,\
    height: `${$data.height}px`,\
    transform: `translate3d(${$data.pos.x}px, ${$data.pos.y}px, 0)`,\
  }"
)
  .adjustable-modal__header(
    @mousedown="onMoveDragStart"
  )
    .header
      .header__title {{ $props.title }}
      .header__close(
        @click="$emit('close')"
      )
  .adjustable-modal__content
    slot
</template>

<script>
import Vue from 'vue';

export default Vue.extend({
  props: {
    isOpen: { type: Boolean },
    title: { type: String },
    initialWidth: { type: Number },
    initialHeight: { type: Number },
  },
  data() {
    return {
      width: this.$props.initialWidth,
      height: this.$props.initialHeight,
      pos: {
        x: (window.innerWidth - this.$props.initialWidth) / 2,
        y: (window.innerHeight - this.$props.initialHeight) / 2,
      },
      isMoveDragging: false,
    };    
  },
  mounted() {
    document.addEventListener('mousemove', this.onDrag);
    document.addEventListener('mouseup', this.onDragEnd);
  },
  beforeDestroy() {
    document.removeEventListener('mousemove', this.onDrag);
    document.removeEventListener('mouseup', this.onDragEnd);
  },
  methods: {
    /**
     * 移動ドラッグ開始時
     * @param {Event} event - イベント
     */
    onMoveDragStart(event) {
      event.preventDefault();
      event.stopPropagation();
      
      this.$data.isMoveDragging = true;
      this.dragStartX = event.clientX;
      this.dragStartY = event.clientY;
      this.startClientRect = {
        x: this.$data.pos.x,
        y: this.$data.pos.y,
        width: this.$data.width,
        height: this.$data.height,
      };
    },
    /**
     * ドラッグ中
     * @param {Event} event - イベント
     */
    onDrag(event) {
      if (this.dragStartX == null || this.dragStartY == null || this.startClientRect == null) {
        return;
      }
      
      // 移動ドラッグの時
      if (this.$data.isMoveDragging) {
        this.$data.pos.x = _.clamp(
          this.startClientRect.x + (event.clientX - this.dragStartX),
          0,
          window.innerWidth - this.$data.width
        );
        this.$data.pos.y = _.clamp(
          this.startClientRect.y + (event.clientY - this.dragStartY),
          0,
          window.innerHeight - this.$data.height
        );
      }
    },
    /**
     * ドラッグ終了時
     */
    onDragEnd() {
      this.$data.isMoveDragging = false;
      this.dragStartX = null;
      this.dragStartY = null;
      this.startClientRect = null;
    },
  },
});
</script>

<style lang="scss">
$z-index-modal: 10;

.adjustable-modal {
  position: absolute;
  top: 0;
  left: 0;
  z-index: $z-index-modal;
  display: flex;
  flex-direction: column;
  background-color: #fff;
  border-radius: 8px;
  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.15);

  &:focus,
  &:focus-within {
    z-index: $z-index-modal + 1;
    outline: 0;
  }

  &__content {
    padding: 0 20px;
    border-top: solid 1px #ddd;
    flex: 1 1 0;
    overflow-x: hidden;
    overflow-y: auto;
  }
}

.header {
  $root: &;

  position: relative;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 20px 40px 20px 20px;
  cursor: grab;

  &:active {
    cursor: grabbing;
  }

  &__title {
    font-weight: 600;
    font-size: 18px;
    white-space: nowrap;
    text-overflow: ellipsis;
    overflow: hidden;
  }
  
  &__close {
    position: absolute;
    top: 50%;
    right: 20px;
    width: 20px;
    height: 20px;
    transform: translateY(-50%);
    cursor: pointer;
    
    &::before, &::after {
      position: absolute;
      top: 50%;
      left: 50%;
      width: 80%;
      height: 2px;
      background-color: #ccc;
      content: '';
    }
    
    &::before {
      transform: translate(-50%, -50%) rotate(45deg);
    }
    
    &::after {
      transform: translate(-50%, -50%) rotate(135deg);
    }
  }
}
</style>
```

## リサイズの実装
### クリック領域の設定
リサイズ処理は少し手間で、まずはどの枠を選択しているか分かるようにしないといけません。
そこで四隅と上下左右それぞれの領域に四角を配置し、そこをクリックさせるようにします。

![移動・リサイズ可能なモーダル.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/187768/3261052c-e539-dba6-b59c-c8c6ff7dc579.png)

そしてそのイベントはJS側で判定できるように情報を持たせておきます。

+ 縦方向: `top`, `bottom`, `null`
+ 横方向: `left`, `right`, `null`

(nullは未指定）


```vue:クリック領域の用意
<template lang="pug">
.adjustable-modal
  //- 他は省略
  .adjustable-modal__resize-top-left-corner(
    @mousedown="(event) => { onResizeDragStart(event, 'left', 'top'); }"
  )
  .adjustable-modal__resize-top-bar(
    @mousedown="(event) => { onResizeDragStart(event, null, 'top'); }"
  )
  .adjustable-modal__resize-top-right-corner(
    @mousedown="(event) => { onResizeDragStart(event, 'right', 'top'); }"
  )
  .adjustable-modal__resize-right-bar(
    @mousedown="(event) => { onResizeDragStart(event, 'right', null); }"
  )
  .adjustable-modal__resize-bottom-right-corner(
    @mousedown="(event) => { onResizeDragStart(event, 'right', 'bottom'); }"
  )
  .adjustable-modal__resize-bottom-bar(
    @mousedown="(event) => { onResizeDragStart(event, null, 'bottom'); }"
  )
  .adjustable-modal__resize-bottom-left-corner(
    @mousedown="(event) => { onResizeDragStart(event, 'left', 'bottom'); }"
  )
  .adjustable-modal__resize-left-bar(
    @mousedown="(event) => { onResizeDragStart(event, 'left', null); }"
  )
</template>

<script>
import Vue from 'vue';

export default Vue.extend({
  methods: {
    /**
     * リサイズドラッグ開始時
     * @param {Event} event - イベント
     * @param {'left' | 'right' | null} resizeDraggingSide - 水平方向のリサイズ対象
     * @param {'top' | 'bottom' | null} resizeDraggingVertical - 垂直方向のリサイズ対象
     */
    onResizeDragStart(event, resizeDraggingSide, resizeDraggingVertical) {
      event.preventDefault();
      event.stopPropagation();
      this.$data.resizeDraggingSide = resizeDraggingSide;
      this.$data.resizeDraggingVertical = resizeDraggingVertical;
      this.dragStartX = event.clientX;
      this.dragStartY = event.clientY;
      this.startClientRect = {
        x: this.$data.pos.x,
        y: this.$data.pos.y,
        width: this.$data.width,
        height: this.$data.height,
      };
    },
  }
});
</script>

<style lang="scss" scoped>
.adjustable-modal {
  // &__contentなどのスタイルは省略

  &__resize-top-bar {
    position: absolute;
    top: 0;
    right: 5px;
    left: 5px;
    height: 5px;
    cursor: ns-resize;
  }

  &__resize-bottom-bar {
    position: absolute;
    right: 5px;
    bottom: 0;
    left: 5px;
    height: 5px;
    cursor: ns-resize;
  }

  &__resize-left-bar {
    position: absolute;
    top: 5px;
    bottom: 5px;
    left: 0;
    width: 5px;
    cursor: ew-resize;
  }

  &__resize-right-bar {
    position: absolute;
    top: 5px;
    right: 0;
    bottom: 5px;
    width: 5px;
    cursor: ew-resize;
  }

  &__resize-top-left-corner {
    position: absolute;
    top: 0;
    left: 0;
    width: 5px;
    height: 5px;
    cursor: nwse-resize;
  }

  &__resize-top-right-corner {
    position: absolute;
    top: 0;
    right: 0;
    width: 5px;
    height: 5px;
    cursor: nesw-resize;
  }

  &__resize-bottom-right-corner {
    position: absolute;
    right: 0;
    bottom: 0;
    width: 5px;
    height: 5px;
    cursor: nwse-resize;
  }

  &__resize-bottom-left-corner {
    position: absolute;
    bottom: 0;
    left: 0;
    width: 5px;
    height: 5px;
    cursor: nesw-resize;
  }
}
</style>
```

### 各項目のリサイズ設定
あとは各方向において適切なリサイズを実装します。４方向にバラして実装することで、斜めのリサイズも柔軟に対応できます。

#### topの場合
topの場合は主にy座標を変更します。y座標を変化させてから、heightを確定させる流れになります。MIN_HEIGHT分は確保しておきたいので、下図のようにbottomYからMIN_HEIGHT分上にいる位置までが限界となります。

![移動・リサイズ可能なモーダル2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/187768/0bd3ba8b-c1ec-cf10-9ec2-46995c666758.png)

```javascript:topの場合のリサイズ
// topのリサイズドラッグの時
if (this.$data.resizeDraggingVertical === 'top') {
  const changeValue = event.clientY - this.dragStartY;
  const bottomY = this.startClientRect.y + this.startClientRect.height;
  this.$data.pos.y = _.clamp(this.startClientRect.y + changeValue, 0, bottomY - MIN_HEIGHT);
  this.$data.height = bottomY - this.$data.pos.y;
}
```

#### bottomの場合
bottomの場合はheightを変更します。こちらはheightだけ調整すればいいので比較的簡単です。範囲はMIN_HEIGHTからウィンドウの一番下（`window.innerHeight - y座標`）になります。

![移動・リサイズ可能なモーダル3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/187768/ee41cc39-f166-fbcb-1e12-490e531d9795.png)

```javascript:bottomの場合のリサイズ
// bottomのリサイズドラッグの時
if (this.$data.resizeDraggingVertical === 'bottom') {
  const changeValue = event.clientY - this.dragStartY;
  this.$data.height = _.clamp(
    this.startClientRect.height + changeValue,
    MIN_HEIGHT,
    window.innerHeight - this.startClientRect.y
  );
}
```

#### left, rightの場合
top, bottomのやり方と同じなので割愛します。

## その他微調整
他には選択したモーダルが最前面に来るようにfocus時に先頭になるようz-indexを調整しています。厳密にはそれだけだとfocusが外れた時に後ろに戻ってしまうのでよくはないですが、簡易的にやる分には良いかなと思っています。
ちゃんとやる場合はきちんとz-indexを管理する必要があると思います。

```vue:フォーカス時に最前面になるように調整
<template lang="pug">
//- focusが効くようにtabindexを設定する
.adjustable-modal(
  tabindex="0"
)
</template>

<style lang="scss" scoped>
$z-index-modal: 10;

.adjustable-modal {
  &:focus,
  &:focus-within {
    // 他のモーダルより1つ大きくして最前面にする
    z-index: $z-index-modal + 1;
  }
}
</style>
```

# 終わりに
以上が移動・リサイズ可能モーダルの実装内容でした。そうそうこれを実装する機会はないと思いますが、もし似たようなものを作ることがあった際に役に立てれば幸いです。
最後に実際のコードをCodePenにアップしていますので、興味がある方は是非みてください。

<p class="codepen" data-height="394" data-theme-id="dark" data-default-tab="js,result" data-user="wintyo" data-slug-hash="KKgyMeJ" style="height: 394px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;" data-pen-title="移動・リサイズ可能なモーダル">
  <span>See the Pen <a href="https://codepen.io/wintyo/pen/KKgyMeJ">
  移動・リサイズ可能なモーダル</a> by wintyo (<a href="https://codepen.io/wintyo">@wintyo</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>
<script async src="https://static.codepen.io/assets/embed/ei.js"></script>