JavaScript 第11回 ウィンドウをドラッグ＆ドロップの改善・注意事項

# はじめに
第9回、10回と作成したウィンドウをドラッグ＆ドロップのソースについて、もう少し改善の余地があり、また、注意することもあるなと思いましたので、まとめます。
なお、第10回のソースをベースとしますので、第9回、第10回を見ていることを前提として記載します。

# 今回実施する内容
- HTMLのbodyの範囲の確認
- `pointermove`が該当要素の外へ出た場合の対応
![drag時の課題対応.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/405480/81b11645-9d17-1f43-739c-e4f4f6132b29.gif)

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

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

# 参考
[position](https://developer.mozilla.org/ja/docs/Web/CSS/position)

# 用語

# 課題
第9回、10回と作成したウィンドウをドラッグ＆ドロップを速く実施したらどうなるか？特に`pointermove`の部分がどうなるのか？というのが疑問でした。
要するに`pointmove`のイベントの発生とJavaScriptによる画面処理では処理が間に合うのか？ということです。
![drag時の課題.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/405480/4a4df68a-0699-aeb4-469c-785242a61455.gif)

実験してみると
- 要素の中心に近いところで、速くポインターを動かしても問題なさそう
- **要素の端で、速くポインターを動かすとウィンドウの移動が止まってしまう**
という状況でした。

`pointmove`のイベント発生の解像度がどのくらいなのか？と気になって調べてみましたが、具体的なものはよくわかりませんでした。

# 課題の原因
課題となった2つの動作の要因を考えてみると、
- `pointermove`とそれによる要素の移動が追い付かず、ポインターが要素の外に出てしまう
ためだと思いました。
そのため、
- `pointermove`イベントを画面全体の`body`に付与すればよいのでは？

と考えました。
でも、実はもうひとつ落とし穴があって、
- `pointerleave`イベントの発生によって`pointermove`処理を止めてしまう
というのもありました。
`pointerleave`は`body`に対して`EventListener`を追加していました。**`body`＝ブラウザ画面内**ではなく、**ブラウザ画面の下部は`body`の外の可能性がある**ということでした。

というのもありました。
ということで、動作を確認していきます。

# `body`の範囲調査
順番が変わってしまいますが、先に`body`の範囲を見ていきます。
結論から言えば、デフォルトでは
- width：ブラウザの画面幅
- height：ソースで記載された内容の最下位の位置

のようです。
もうひとつ注意が必要なのは、
- positionの設定によって`body`の範囲は変わりうる
    - `static`や`relative`のような**通常のフロー**になるものは`body`の範囲の対象ですが、`absolute`や`fixed`のようなものは範囲の対象外

です。

:::note info
`position`については、[JavaScript(でもほぼCSS) 第7回 positionの動作](https://qiita.com/Mount/items/2931a2d92e23b345b9cb)参照ください。
:::

## `body`の範囲調査
`body`の範囲について確認します。
第10回のソースをもとにして、
- 「画面上部表示」を押下すると、コンソールに`body`の幅、高さを表示するようにします。`body`の幅や高さは未設定、すなわち、デフォルトの状態です。
- また、`topDiv`の`div`要素を押下して、ポインターを`body`範囲外に移動すると、その時のポインター位置をコンソールに表示します。

画面のイメージは「課題」のところのGIFイメージを参照ください。

<details><summary>body_range.htmlのソースコード</summary>

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

```css:body_range.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: visible;
  background-color:yellow;
  position: absolute;
  width: 300px;
  height: 100px;
  top: 0px;
  left: 20%;
}
```
</details>

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

```javascript:body_range.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";
    console.log(`body offsetwidth:${document.body.offsetWidth}`);
    console.log(`body offsetHeight:${document.body.offsetHeight}`);
}

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

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

//"topDiv"にマウスダウン、マウスアップ時のEventLisnterを追加。
document.getElementById("topDiv").addEventListener("pointerdown", handlePointerDown);
//document.getElementById("topDiv").addEventListener("pointerup", handlePointerUp);

/**
 * マウス、もしくは、タッチで要素がアクティブになった時に、
 * - その座標の保存。
 * - ドラッグによる移動時のEventListnerを追加。×画面の上に表示されます。
 * - ドラッグしながらポインタが画面外移動時のEventListenerを追加。
 * 
 * @param {object} event コールバック関数は発生したイベントを説明するEventに基づくオブジェクト。
 */
function handlePointerDown(event) {
    getElemOffset(event);
//    document.getElementById(elemId).addEventListener("pointermove", dragWindow);
    document.body.addEventListener("pointerleave", showPointerPos);
}

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

/**
 * body外移動時にポインターの位置を表示。
 * 
 * @param {object} event コールバック関数は発生したイベントを説明するEventに基づくオブジェクト。
 */
function showPointerPos(event) {
    console.log(`pointer pageX:${event.pageX}`);
    console.log(`pointer pageY:${event.pageY}`);
}
```
</details>

画面上部表示時に実行する処理は以下の部分です。
```javascript
function showWindow(divId) {
    document.getElementById(divId).style.visibility = "visible";
    console.log(`body offsetwidth:${document.body.offsetWidth}`);
    console.log(`body offsetHeight:${document.body.offsetHeight}`);
}
```
`document.body.offsetWidth`と`document.body.offsetHeight`の値をコンソールに表示します。
「画面上部表示」を押下すると、今回の私の環境ではコンソールに`body`の幅と高さが表示されました。
```
body offsetwidth:818
body offsetHeight:48
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/405480/3c708557-67e7-22c5-66a5-7dfe93bf0943.png)

赤枠部分がbodyの範囲で、黒枠がブラウザの画面範囲です。
これをみてわかるように、黄色のdiv要素は`body`の範囲ではないのです。この要素はCSSで`position:absolute`としたため、**要素は文書の通常のフローから除外され**ているのだと思います。

`topDiv`の`div`要素を押下して、ポインターを`body`範囲外に移動したときには、コンソールにポインター位置が表示されます。今回の私の環境では、以下のような感じでした。
```
pointer pageX:155
pointer pageY:58
pointer pageX:303
pointer pageY:100
```
これは、2回分の`pointerleave`の結果ですが、発生した場所はそれぞれ違います。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/405480/88ed7e26-4a0f-300d-e7f2-ba43f452f7a8.png)
赤丸で示したところが発生した場所です。
1つ目の「pageX:155、pageY:58」のところは、`document.body.offsetHeight=48`であることを考えると、妥当な値です。
2つ目の「pageX:303、pageY:100」のところは、`document.body.offsetHeight=48`であることを考えると、？？？となりませんか？
調査してみると、この`topDiv`の`div`要素も**通常のフロー**からは除外されているものの、`body`の一部のようです。
したがって、`body`の範囲は、以下の図の赤枠のようになります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/405480/7848b8b8-b71e-12c1-f9f1-44c751b0ca0c.png)
ちなみに、`topDiv`の`div`要素をもっと下に配置して本来の`body`よりも下に配置しても、この`topDiv`の要素は`body`の一部で変わりません。

# `pointermove`による要素の移動が追い付かず、ポインターが要素の外にでてしまう動作
「課題」に載せているアニGIFの動作を、「`body`の範囲調査」をふまえてみてみると、**ポインターが要素の外にでてしまう**動作には２つの要因がありそうだと想像つきました。
すなわち、要素の移動が追い付かない動作は、以下の時に発生しそうということです。
- ポインターが要素の外にでた場合
- ポインターが`body`の範囲外にでた場合

1つ目については対策をこの後記載します。2つ目については`body`の範囲が今回のソースのようにウィンドウの移動する範囲を考慮した大きさにしておくくらいしかないのかなと思いました。

## ポインターが要素の外にでた場合の対策
第9回で本動作を作成した時の想定動作は、
- ドラッグでウィンドウ移動開始
- ドラッグ中ウィンドウの移動
- ドロップでウィンドウ移動終了

でした。
ウィンドウの移動がポインターの移動に追い付いておらず、かつ、`pointermove`を実装している要素を、`div`要素に限定しており、要素の外にポインターがでてしまうということなので、**`pointermove`を実装する要素を`div`要素ではなく、`body`にすればよい** と思いました。
ということで、第10回のJavaScriptのソースに以下の4点の修正を加えます。
- `pointerup`を`topDiv`の`div`要素から`body`へ実装するように変更
- `handlePointerDown`関数内の`pointermove`を`elemId`（要するに`topDiv`）から`body`へ実装するように変更
- `handlePointerUp`関数内の`pointermove`を`elemId`（要するに`topDiv`）から`body`へ実装削除するように変更
- `cancelDragWindow`関数内の`pointermove`を`elemId`（要するに`topDiv`）から`body`へ実装削除するように変更

また、htmlのソースも`body`の範囲を拡張するため、`<br>`で改行を追加します。
<details><summary>modeless_drag_pointer2.htmlのソース（第10回から変更あり）</summary>

```html:modeless_drag_pointer2.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>dragpointer2</title>
    <link rel="stylesheet" href="modeless_drag_pointer2.css" type="text/css">
    <script src="modeless_drag_pointer2.js" defer></script>
</head>
<body>
    <div>- 画面表示 -</div>
    <div id="showTop">画面上部表示</div>
    <div id="topDiv">
        <div class="closeCircle" id="closeTop">×</div>
        画面の上に表示されます。
    </div>
    <br>
    <br>
    <br>
    <br>
    <br>
    <br>
    <br>
    <br>
    <br>
    <br>
    <br>
    <br>
    <br>
    <br>
    <br>
    <br>
</body>
</html>
```
</details>
<details><summary>modeless_drag_pointer2.cssのソース（第10回から変更なし）</summary>

```css:modeless_drag_pointer2.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_pointer2.js（第10回から変更あり）</summary>

```javascript:modeless_drag_pointer2.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("pointerdown", handlePointerDown);
document.body.addEventListener("pointerup", handlePointerUp);

/**
 * マウス、もしくは、タッチで要素がアクティブになった時に、
 * - その座標の保存。
 * - ドラッグによる移動時のEventListnerを追加。×画面の上に表示されます。
 * - ドラッグしながらポインタが画面外移動時のEventListenerを追加。
 * 
 * @param {object} event コールバック関数は発生したイベントを説明するEventに基づくオブジェクト。
 */
function handlePointerDown(event) {
    getElemOffset(event);
    document.body.addEventListener("pointermove", dragWindow);
    document.body.addEventListener("pointerleave", cancelDragWindow);
}

/**
 * マウス、もしくは、タッチで要素が非アクティブになった時に、
 * - ドラッグによる移動時のEventListenerを削除。
 * 
 * @param {object} event コールバック関数は発生したイベントを説明するEventに基づくオブジェクト。
 */
function handlePointerUp(event) {
    document.body.removeEventListener("pointermove", dragWindow);
    document.body.removeEventListener("pointerleave", 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.body.removeEventListener("pointermove", dragWindow);
    document.body.removeEventListener("pointerleave", cancelDragWindow);
}
```
</details>

上記を動作させると以下の通りになります。
![drag時の課題対応.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/405480/284e64f4-5d38-ceb4-e793-f7c4b558dda8.gif)

`body`の範囲内であれば、ポインターを速く動作させてもウィンドウは追従して動作します。
しかし、`body`の範囲外の場合、ポインターを速く動作させるとウィンドウは追従せず止まります（`<br>`でで`body`の範囲を広げましたが、それより下にポインターが移動すると対応できないということです）。

# おわりに
今回はウィンドウのドラッグ＆ドロップの動作に高速ポインター移動にある程度対応できるようにソースの改善を行いました。
`body`の範囲外に移動した場合の動作については解消できませんでしたが、画面の下端はある程度なんとでもなるのかなと思います。