JavaScript 第９回 ウィンドウをドラッグ＆ドロップで移動

# はじめに
今回は、第８回で作成したモードレスウィンドウをドラッグ＆ドロップで移動できるようにします。
モードレスウィンドウの作成については、[第８回の記事](https://qiita.com/Mount/items/dc3eda3a0b38939ed98a)を参照ください。
なお、**もう少し改善の余地があることがわかりましたので、そのあたりは今後また記事にしたい** と思います。

# 今回実施する内容
モードレスウィンドウをマウスによるドラッグ＆ドロップで移動できるようにします。
![dandd.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/405480/f8055a91-d94a-6789-1f53-dbe84927f8cd.gif)

# ソースコード(Git Hub)
https://github.com/Build-Mount/JS_09_drag

# 環境
OS: Windows 11 JP (64bit)
Microsoft Edge：バージョン 120.0.2210.91 (公式ビルド) (64 ビット)

# 参考
[mousedown](https://developer.mozilla.org/ja/docs/Web/API/Element/mousedown_event)
[mousemove](https://developer.mozilla.org/ja/docs/Web/API/Element/mousemove_event)
[mouseup](https://developer.mozilla.org/ja/docs/Web/API/Element/mouseup_event)
[mouseleave](https://developer.mozilla.org/ja/docs/Web/API/Element/mouseleave_event)
[MouseEvent: pageX プロパティ](https://developer.mozilla.org/ja/docs/Web/API/MouseEvent/pageX)
[MouseEvent: pageY プロパティ](https://developer.mozilla.org/ja/docs/Web/API/MouseEvent/pageY)
[MouseEvent: offsetX プロパティ](https://developer.mozilla.org/ja/docs/Web/API/MouseEvent/offsetX)
[MouseEvent: offsetY プロパティ](https://developer.mozilla.org/ja/docs/Web/API/MouseEvent/offsetY)

# 用語
なし

# ウィンドウのドラッグ＆ドロップ動作
ウィンドウのマウスによるドラッグ＆ドロップの動作は、主に以下の３つです。
- **ドラッグ**でウィンドウ移動開始
- **ドラッグ中**ウィンドウの移動
- **ドロップ**でウィンドウ移動終了
 
これに加えて、
- **マウスポインタがブラウザーの画面外**に移動でウィンドウ移動終了

を考慮します。

ドラッグやドロップ動作は、マウスイベントを`EventListener`として実装することで検知することができます。
**マウスイベントが検知されたタイミングで、ウィンドウの移動を開始、終了する**ことで、ウィンドウのドラッグ＆ドロップを実現できます。

対応するマウスイベントは、以下の通りです。

|マウス動作|マウスイベント|ウィンドウ動作|
|:--|:--|:--|
|ドラッグ|mousedown|移動開始|
|ドラッグ中|mousemove|移動|
|ドロップ|mouseup|移動終了|
|画面外|mouseleave|移動終了|

マウスイベントの概要については、[Element](https://developer.mozilla.org/ja/docs/Web/API/Element)配下に記載があります。
|マウスイベント|概要|
|:--|:--|
|mousedown|`mousedown` イベントは、ポインターが要素の中にあるときにポインティングデバイスのボタンが押下されたとき、その要素 (`Element`) に発行されます。|
|mousemove|`mousemove` イベントは、カーソルのホットスポットが要素内にあるときに、ポインティングデバイス (通常はマウス) が移動されると、その要素に発行されます。|
|mouseup|`mouseup` イベントは、ポインターが要素の中にあるときに、ポインティングデバイス (マウスやトラックパッドなど) のボタンが離されるとその要素 (`Element`) に発行されます。|
|mouseleave|`mouseleave` イベントは、ポインティングデバイス（ふつうはマウス）のカーソルが要素 (`Element`) の外に移動したときに発行されます。|

図にすると以下の通りです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/405480/a21639d6-0c1f-bae8-07bd-1f2dd65a6233.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/405480/a9b44dee-7359-4352-7999-a6e9dc0a7353.png)

# ウィンドウのドラッグ＆ドロップ動作の実装動作
## ウィンドウのドラッグ＆ドロップイベントと実装タイミング
マウスイベントに記載した通り、ドラッグ＆ドロップ用のマウスイベントは存在しません。
上記で記載した`mousemove`は、 **カーソルのホットスポットが要素内にあるときに、ポインティングデバイス (通常はマウス) が移動されると、その要素に発行されます** から、**ドラッグ中でなくても** 、要素内にマウスポインターが入れば連続で発行されます。

したがって、マウスイベントからドラッグ＆ドロップ動作を模擬する必要があります。

例えば、`mousemove`は、**ドラッグ＆ドロップをしているときにだけ、すなわち、ドラッグしてからドロップするまでの間だけ、`mousemove`が発行**されるようにする必要があり、その間だけドラッグしている要素を移動するような動作が必要です。

また、画面外に移動したときのイベント`mouseleave`は、`mousemove`と同様にドラッグ＆ドロップのウィンドウ移動の終了の契機にする必要があります。そうしないと、マウスポインターが画面外に出て、再度画面内に戻ってきた時に、ウィンドウの移動動作を実施してしまいます。

上記をふまえて、どの状態でどのマウスイベントが必要かを示します。
|状態|必要なマウスイベント|
|:--|:--|
|ドラッグ前|`mousedown`|
|ドラッグ中|`mousemove`、`mouseleave`、`mouseup`|
|ドロップ後|`mousedown`|

「ドラッグ前」と「ドロップ後」は状態としては同じですから、結局、２つの状態にわかれます。
これを実現するために、どのイベントをいつ、どの要素に`EventListener`で実装するかを示します。
|マウスイベント|要素|`EventListener`タイミング|補足|
|:--|:--|:--|:--|
|`mousedown`|ウィンドウ|常に実装|「ドラッグ中」でも悪影響がないため。|
|`mouseup`|ウィンドウ|常に実装|「ドラッグ前後」でも悪影響がないため。|
|`mousemove`|ウィンドウ|「ドラッグ」時に追加<br>「ドロップ」で削除|「ドラッグ中のみ動作させたいため。」
|`mouseleave`|`body`|「ドラッグ」時に追加<br>「ドロップ」で削除|「ドラッグ中のみ動作させたいため。」

図にまとめると以下の通りです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/405480/9458fc97-4819-ce3c-6d1c-d44bf1967fce.png)

登録する要素は、ウィンドウと`body`としました。
`mousedown`、`mouseup`、は`mousemove`は移動する該当の要素で開始、終了が必要なためこのようにしました。
`mouseleave`は`body`としており、ブラウザ自身の画面外へ出たときに発生することを想定するためです。

## ドラッグ、ウィンドウの座標取得と移動時の座標
ドラッグ＆ドロップによるウィンドウを移動の動作は、以下の通りです。
**1. ドラッグ時のマウスポインターの座標取得**
**2. ウィンドウの座標算出**
**3. マウスポインター移動(`mousemove`イベント)検出時に、マウスポインターの座標取得**
**4. 移動すべきウィンドウの座標算出し、ウィンドウを移動**
**5. 3, 4繰り返し**
**6. ドロップ(mouseup`イベント)検出で、ウィンドウ移動終了**

今回のウィンドウは、`div`要素で実現します。
`div`要素をドラッグ時のマウスポインターの座標取得、`div`要素の座標取得、および、`div`要素の座標設定で使用するプロパティは以下の通りです。
|取得/設定|使用するプロパティ|
|:--|:--|
|ドラッグ時のマウスポインター座標取得|`pageX`、`pageY`、`offsetX`、`offsetY`|
|`div`要素座標取得|`pageX`、`pageY`、`offsetX`、`offsetY`から算出|
|`div`要素座標設定|`top`、`left`|

### ドラッグ時のマウスポインター座標、ウィンドウの座標取得
ドラッグ時のマウスポインターの座標は、`pageX`、`pageY`、`offsetX`、`offsetY`で示されます。

[MouseEvent: pageX プロパティ](https://developer.mozilla.org/ja/docs/Web/API/MouseEvent/pageX)には、以下の記載があります。

>`pageX` は `MouseEvent` インターフェイスの読み取り専用プロパティで、マウスがクリックされた位置の X（水平）座標を、文書全体の左端からの相対座標で返します。 これには文書の現在見えていない範囲にあるものも含みます。

[MouseEvent: pageY プロパティ](https://developer.mozilla.org/ja/docs/Web/API/MouseEvent/pageY)には、以下の記載があります。

>`pageY` は `MouseEvent` インターフェイスの読み取り専用プロパティで、マウスがクリックされた位置の Y （垂直）座標を、文書全体の相対座標で返します。 このプロパティはページの垂直スクロールを加味します。

[MouseEvent: offsetX プロパティ](https://developer.mozilla.org/ja/docs/Web/API/MouseEvent/offsetX)には、以下の記載があります。

>`offsetX` は `MouseEvent` インターフェイスの読み取り専用プロパティで、マウスポインターの X 座標におけるこのイベントと対象ノードのパディング辺との間のオフセットを提供します。

[MouseEvent: offsetY プロパティ](https://developer.mozilla.org/ja/docs/Web/API/MouseEvent/offsetY)には、以下の記載があります。

>`offsetY` は `MouseEvent` インターフェイスの読み取り専用プロパティで、マウスポインターの Y 座標におけるこのイベントと対象ノードのパディング辺との間のオフセットを提供します。

上記を図にすると以下の通りです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/405480/ed22e1ca-a622-7342-4623-09098bc9af8b.png)

:::note info
`offsetX`、および、`offsetY`の対象ノードとパディング辺については、説明を割愛します。
:::

ウィンドウの左上の座標は、`pageX`、`pageY`、`offsetX`、`offsetY`から算出します。
- 左上のX軸座標：`pageX` - `offsetX`
- 左上のY軸座標：`pageY` - `offsetY`

これを図にすると以下の通りです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/405480/d1483fd0-ac89-ac71-493f-94436d022d23.png)

### ウィンドウの移動
ウィンドウ移動時の`top`、`left`の値は、以下のように算出します。
- **left**：`pageX` - `offsetX`
- **top**：`pageY` - `offsetY`

これを図にすると以下の通りです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/405480/ef489320-c4e2-aae1-ad1b-1c76e705a439.png)

ウィンドウの移動は、ドラッグ中`mousemove`イベントが常に発生しますので、連続で`top`、`left`を設定することで、ウィンドウを移動させることができます。
ドラッグ時、および、ドラッグで移動した時の`mousemove`イベント発生時に着目すると、図の通りとなり、ドラッグ中は`pageX`と`pageY`はマウスの移動で変化しますが、ウィンドウは一緒に移動させるため、`offsetX`、および、`offsetY`は変化しないことがわかると思います。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/405480/b0a8ab7a-28cc-0cb8-3c63-b61c32c37367.png)

ということは、ドラッグ開始時に、`pageX`、`pageY`、`offsetX`、および、`offsetY`を取得したら、ドラッグ中は、`pageX`、および、`pageY`だけ取得すれば、ウィンドウの移動座標を算出できることがわかります。

:::note info
ウィンドウの座標設定にあたり、以下に注意する必要があります。
- `position`の設定値については、[JavaScript(でもほぼCSS) 第7回 positionの動作](https://qiita.com/Mount/items/2931a2d92e23b345b9cb)で取り上げましたが、画面の左上からの位置にするならば、`position: fixed`が良いと思いますが、ウィンドウの親要素が`body`となるのであれば、`position: absolute`でもよいと思います。今回のサンプルでは、`position: absolute`で試します。
- [JavaScript(でもほぼCSS) 第7回 positionの動作](https://qiita.com/Mount/items/2931a2d92e23b345b9cb)で取り上げましたが、`position: fixed`や`position: absolute`を使用する場合、`top`と`bottom`を設定し`height`を設定しないと、`height`が自動調整されますし、`left`と`right`を設定し`width`を設定しないと、`width`が自動調整されますので、注意ください。もし、そのような設定を行うとドラッグ中にウィンドウのサイズが自動調整によって変更され、ドラッグ＆ドロップ動作になりません。
:::

ウィンドウのドラッグ＆ドロップ動作に加えて座標位置取得と移動を図にまとめると以下の通りです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/405480/f5e3820c-55e8-f789-d78c-11c2d532e0df.png)

:::note info
Boldしたところが前図からの更新箇所です。
:::

# ウィンドウをドラッグ＆ドロップで移動する具体的な実装
## ソース
<details><summary>modeless_drag.htmlのソースコード</summary>

```html:modeless_drag.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Position</title>
    <link rel="stylesheet" href="modeless_drag.css" type="text/css">
    <script src="modeless_drag.js" defer></script>
</head>
<body>
    <div>- 画面表示 -</div>
    <div id="showTop">画面上部表示</div>
    <div id="topDiv">
        <div class="closeCircle" id="closeTop">×</div>
        画面の上に表示されます。
    </div>
</body>
</html>
```
</details>
<details><summary>modeless_drag.cssのソースコード</summary>

```css:modeless_drag.css
.closeCircle {
  width: 18px;
  height: 18px;
  border-radius: 50%;
  font-size: 16px;
  line-height: 18px;
  text-align: center;
  position: absolute;
  right: 3px;
  top: 2px;
}

#topDiv .closeCircle {
  background-color: orange;
}

#topDiv {
  visibility: hidden;
  background-color:yellow;
  position: absolute;
  width: 300px;
  height: 100px;
  top: 0px;
  left: 20%;
}
```
</details>

<details><summary>modeless_drag.jsのソースコード</summary>

```javascript:modeless_drag.js
/** modeless用の設定 */
document.getElementById("showTop").addEventListener("click", () => {
    showWindow("topDiv");
});

document.getElementById("closeTop").addEventListener("click", () => {
    closeWindow("topDiv");
});

function showWindow(divId) {
    document.getElementById(divId).style.visibility = "visible";
}

function closeWindow(divId) {
    document.getElementById(divId).style.visibility = "hidden";
}

/** ドラッグ＆ドロップで移動する設定*/
var elemId;             //ドラッグした要素のId
var elemOffsetX;        //ドラッグした要素のoffsetX
var elemOffsetY;        //ドラッグした要素のoffsetY

//"topDiv"にマウスダウン、マウスアップ時のEventLisnterを追加。
document.getElementById("topDiv").addEventListener("mousedown", funcMouseDown);
document.getElementById("topDiv").addEventListener("mouseup", funcMouseUp);

/**
 * マウスで要素をマウスダウンした時に、
 * - その座標の保存。
 * - ドラッグによる移動時のEventListnerを追加。
 * - ドラッグしながらマウスポインタが画面外移動時のEventListenerを追加。
 * 
 * @param {object} event コールバック関数は発生したイベントを説明するEventに基づくオブジェクト。
 */
function funcMouseDown(event) {
    getElemOffset(event);
    document.getElementById(elemId).addEventListener("mousemove", dragWindow);
    document.body.addEventListener("mouseleave", cancelDragWindow);
}

/**
 * マウスで要素をマウスアップした時に、
 * - ドラッグによる移動時のEventListenerを削除。
 * 
 * @param {object} event コールバック関数は発生したイベントを説明するEventに基づくオブジェクト。
 */
function funcMouseUp(event) {
    document.getElementById(elemId).removeEventListener("mousemove", dragWindow);
    document.body.removeEventListener("mouseleave", cancelDragWindow);
}

/**
 * 要素のオフセット(offsetX、offsetY)、およびその要素のIdを保存。
 * 
 * @param {object} event コールバック関数は発生したイベントを説明するEventに基づくオブジェクト。
 */
function getElemOffset(event) {
    elemOffsetX = event.offsetX;
    elemOffsetY = event.offsetY;
    elemId = event.target.id;
}

/**
 * マウスで要素をドラッグ時に、ドラッグした要素を移動。
 * 
 * @param {object} event コールバック関数は発生したイベントを説明するEventに基づくオブジェクト。
 */
function dragWindow(event) {
    document.getElementById(elemId).style.left = event.pageX - elemOffsetX + "px";
    document.getElementById(elemId).style.top = event.pageY - elemOffsetY + "px";
}

/**
 * bodyからマウス移動時のdragWindowのEventListenerを削除。
 * 
 * @param {object} event コールバック関数は発生したイベントを説明するEventに基づくオブジェクト。
 */
function cancelDragWindow(event) {
    document.getElementById(elemId).removeEventListener("mousemove", dragWindow);
    document.body.removeEventListener("mouseleave", cancelDragWindow);
}
```
</details>

## modeless_drag.htmlの説明
:::note info
本ソースは、[第8回の記事](https://qiita.com/Mount/items/dc3eda3a0b38939ed98a)で説明しています。
:::
<details><summary>modeless_drag.htmlの説明</summary>

HTMLは、「画面上部表示」の`div`タグ作成と、その画面上部用の`div`タグ作成を行います。
「画面上部表示」は、`id="showTop"`で`divタグを作成します。
これを押下すると、画面上部のモードレスウィンドウを開くようにしますが、それはJavaScriptで設定します。
`id="topDiv"`の`div`タグは、画面上部に表示するモードレスウィンドウの本体です。
この中には、
- モードレスウィンドウをクローズするｘボタン（クローズボタン）。
- 「画面の上に表示されます。」の文字列。
が含まれます。

クローズボタンは、`<div class="closeCircle" id="closeTop">×</div>`で`div`タグで作成します。
`class="closeCircle"は、クローズボタンを作成するためのクラスです。`id="closeTop"`は、クローズボタンを押下時にモードレスウィンドウを非表示に切り替えるためのJavaScriptが必要ですので、関連付けるためのidとして設定します。
ボタンとなる文字は、「×」を記載しています。フォントは特に指定していませんが、ユーザー環境によっては「ｘ」に見えない可能性もありますが、簡単のためこのようにします。せんが、ユーザー環境によっては「ｘ」に見えない可能性もありますが、簡単のためこのようにします。
</details>

## modeless_drag.cssの説明
本ソースは、ほとんど[第8回の記事](https://qiita.com/Mount/items/dc3eda3a0b38939ed98a)で説明しています。
第8回との違いは、`#topDiv`で`width`、`height`、`top`、および、`left`を設定していることくらいです。
これを設定した理由は、「ウィンドウの移動」のところで説明したとおり、ウィンドウのサイズを確定するためです。

## modeless_drag.jsの説明
`/** modeless用の設定 */`の部分は、[第8回の記事](https://qiita.com/Mount/items/dc3eda3a0b38939ed98a)で説明していますので、`/** ドラッグ＆ドロップで移動する設定*/`から説明します。

```javascript
var elemId;             //ドラッグした要素のId
var elemOffsetX;        //ドラッグした要素のoffsetX
var elemOffsetY;        //ドラッグした要素のoffsetY
```

`elemId`は、ドラッグした要素の`Id`を保持する変数です。複数の要素が存在する場合にどの要素をドラッグ＆ドロップするかを設定できるようにします。
`elemOffsetX`、および、`elemOffsetY`はすでに説明したウィンドウの座標を算出するために使用します。

```javascript
//"topDiv"にマウスダウン、マウスアップ時のEventLisnterを追加。
document.getElementById("topDiv").addEventListener("mousedown", funcMouseDown);
document.getElementById("topDiv").addEventListener("mouseup", funcMouseUp);
```

`mousedown`、および、`mouseup`は常に実装するため`topDiv`に付与します。それぞれ、`funcMouseDown`、および、`funcMouseUp`のコールバック関数を実行します。

```javascript
/**
 * マウスで要素をマウスダウンした時に、
 * - その座標の保存。
 * - ドラッグによる移動時のEventListnerを追加。
 * - ドラッグしながらマウスポインタが画面外移動時のEventListenerを追加。
 * 
 * @param {object} event コールバック関数は発生したイベントを説明するEventに基づくオブジェクト。
 */
function funcMouseDown(event) {
    getElemOffset(event);
    document.getElementById(elemId).addEventListener("mousemove", dragWindow);
    document.body.addEventListener("mouseleave", cancelDragWindow);
}
```
「ウィンドウの移動」やソース内のコメントに記載した通り、ドラッグ時に`getElemOffset`関数で`offsetX`、`offsetY`を取得し、その後、`mousemove`、および、`mouseleave`を実装します。
`mousemove`、および、`mouseleave`は、`dragWindow`、および、`cancelDragWindow`のコールバック関数を実行します。

```javascript
/**
 * マウスで要素をマウスアップした時に、
 * - ドラッグによる移動時のEventListenerを削除。
 * 
 * @param {object} event コールバック関数は発生したイベントを説明するEventに基づくオブジェクト。
 */
function funcMouseUp(event) {
    document.getElementById(elemId).removeEventListener("mousemove", dragWindow);
    document.body.removeEventListener("mouseleave", cancelDragWindow);
}
```
`mouseup`時に`mousemove`と`mouseleave`を削除します。

```javascript
/**
 * 要素のオフセット(offsetX、offsetY)、およびその要素のIdを保存。
 * 
 * @param {object} event コールバック関数は発生したイベントを説明するEventに基づくオブジェクト。
 */
function getElemOffset(event) {
    elemOffsetX = event.offsetX;
    elemOffsetY = event.offsetY;
    elemId = event.target.id;
}
```
「ウィンドウの移動」やソース内のコメントに記載した通り、ドラッグ時に`getElemOffset`関数で`offsetX`、`offsetY`を取得します。`elemId`は複数のウィンドウのドラッグ＆ドロップを実現できるようにドラッグをした対象の要素のIdを保持します。

```javascript
/**
 * マウスで要素をドラッグ時に、ドラッグした要素を移動。
 * 
 * @param {object} event コールバック関数は発生したイベントを説明するEventに基づくオブジェクト。
 */
function dragWindow(event) {
    document.getElementById(elemId).style.left = event.pageX - elemOffsetX + "px";
    document.getElementById(elemId).style.top = event.pageY - elemOffsetY + "px";
}
```
すでに説明した通り、ウィンドウを移動する動作です。

```javascript
/**
 * bodyからマウス移動時のdragWindowのEventListenerを削除。
 * 
 * @param {object} event コールバック関数は発生したイベントを説明するEventに基づくオブジェクト。
 */
function cancelDragWindow(event) {
    document.getElementById(elemId).removeEventListener("mousemove", dragWindow);
    document.body.removeEventListener("mouseleave", cancelDragWindow);
}
```
画面外に移動時に`mousemove`と`mouseleave`を削除します。

# おわりに
今回は、モーダルウィンドウの流れで、ウィンドウをドラッグ＆ドロップする操作を作りました。
試したところ、これでは不十分なことが色々とありましたので、次回以降でその辺をまとめていこうと思います。