【JavaScript】スマホ向けサイトでロングタップを実装したい

# 0. INDEX
1. 概要
2. 前準備
3. 実装
4. あとがき


# 1. 概要
スマホ向けWebアプリ開発で、ロングタップ（押しっぱなし）的なトリガーがあると思い込んでいたが為にやらかしました。

![longtap.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/92447/5d6f05e5-a896-388e-1d68-e30f9988d03e.jpeg)
[^salesman] [^shachiku-chan]

[^salesman]: 弊社にはこのような営業はおりません。全ては私の中にいる悪魔（サービス精神旺盛）の所業であり、ただの自爆です。

[^shachiku-chan]: マンガは **社畜ちゃん台詞メーカー** で作りました。  
https://blog.oukasoft.com/OS/scenemake/

という事で、スマホアプリのファイラー等でよく見かける、**ロングタップをするとファイルを選択する** という感じの事をWebアプリでもやりたくて、 **ロングタップを判断するユーティリティ** を作り~~ゴリ押し~~ました。


# 2. 前準備
**ロングタップとは何か？** という事をユーザーとシステムの目線で見て実装内容を考えてみます。


## 2.1. ユーザー目線のロングタップ操作
多分、こんな感じ。
- スマホ画面を指で触り始める  
※指を動かさない（ドラッグしない）ってのもあるかもしれないですが、今回は割愛します
- 触ったまま一定時間経ったら、長押し用のメニュー等が出てくる

## 2.2. システム目線のロングタップ操作
上記を踏まえて、こういう感じで実装できそうです。
- `touchstart` トリガーを捕まえて処理を開始する
- 一定時間押しっぱなしだったと判断する為に、`setTimeout()`を仕掛ける … （1）
- 一定時間押しっぱなしじゃなかったと判断する為に、`touchend`トリガーを仕掛ける … （2）

### もうちょっと分解
- （1）が先に発動したら、**ロングタップをした**
- （2）が先に発動したら、**ロングタップではなかった**

## 2.3. つまりどーゆー事だっぺよ？（図解）
そういえば私は弊社の大バカ野郎担当なので、言葉だけではイマイチ理解できません。なのでお絵描き[^oekaki]をします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/92447/e8e13cd3-cded-52c5-e3b3-880da2276711.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/92447/a902e055-9961-d2c8-24d2-cbbc13c9357a.png)

[^oekaki]: お絵描き後、先に書いていたテキストは要らねーじゃんと思ったり思わなかったりしましたω


# 3. 実装
前準備を元に実装しました。

## 3.1. 動作サンプル
https://jsfiddle.net/mahny/mg48pub3/11/
※JSFiddleの方はPCでも確認できるように「onMouseDown」の処理も入っています。

## 3.2. コード抜粋

```js:ユーティリティ全文
function isHoldDown(targetElement, thresholdMsec = 1000) {
  return new Promise((resolve) => {

    const timerId = setTimeout(() => {
      resolve(true);
      removeListener();
    }, thresholdMsec);

    const touchendHandler = () => {
      resolve(false);
      removeListener();
    };

    const contextHandler = (event) => {
      event.preventDefault();
    }

    const beforeTargetStyle = targetElement.style.userSelect;

    const removeListener = () => {
      clearTimeout(timerId);
      targetElement.removeEventListener('touchend', touchendHandler);
      targetElement.removeEventListener('contextmenu', contextHandler);
	  targetElement.style.userSelect = beforeTargetStyle;
    };

    targetElement.addEventListener('touchend', touchendHandler);
    targetElement.removeEventListener('contextmenu', contextHandler);
    targetElement.style.userSelect = 'none';
  });
}
```

```html:使い方（html）
<h2>押しっぱなし判定習作</h2>
<div>
  <button onTouchStart="test(event)">押しっぱなし！</button>
  <div id="result-console">（判定結果）</div>
</div>
```

```js:使い方（JavaScript）
async function test(event) {
  if (await isHoldDown(event.target)) {
    console.log('ロングタップ');
  } else {
    console.log('普通のタップ');
  }
}
```

## 3.3. コード解説
このコードは、`onTouchStart`をトリガーにするなど、ユーザーの指が既に画面に触れている事を前提としています。
以降、`isHoldDown()`について解説します。

### インターフェース周り
```js
function isHoldDown(targetElement, thresholdMsec = 1000) {
  return new Promise((resolve) => {
    // ～（略）～
  }
}
```
- ユーティリティとしては、単純にtrue/falseで判定したかったので、Promiseを使っています
    - その為、呼び出し側は **async/await** を使う作りになっている
- サンプルだしエラー処理もいらなかったので、`reject`を入れていませんが、必要なら追加する事


### ハンドラ周り
```js
    // ロングタップであると判定する為のハンドラ
    const timerId = setTimeout(() => {
      resolve(true);
      removeListener();
    }, thresholdMsec);

    // ロングタップではないと判定する為のハンドラ
    const touchendHandler = () => {
      resolve(false);
      removeListener();
    };

    // コンテキストメニューが出てくるのを抑制する為のハンドラ
    const contextHandler = (event) => {
      event.preventDefault();
    }
```
- 重要なのは、`setTimeout`と`touchendHandler`で以下の処理を担います
    - `setTimeout`
        - `touchend`が発生しなかったので、**タップし続けた⇒ロングタップである** ので、`resolve(true)` で返しています
        - 呼び出し元で `await` を使っていれば、戻り値として `true` が得られますね
    - `touchendHandler`
        - `setTimeout`が発生する前に指を離してしまったので、**ロングタップではない**。`resolve(false)`で返しています
        - 同様に、呼び出し元で `await` を使っていれば、戻り値として `false` が得られますね
    - `contextHandler`
        - 単に、コンテキストメニューを封じたかったので、`contextmenu`トリガーに紐づけるハンドラとして定義しています。  
        スマホ専用サイトの場合は `contextmenu` 自体が無いので不要です


### トリガー周り、他
```js
    const beforeTargetStyle = targetElement.style.userSelect;

    const removeListener = () => {
      clearTimeout(timerId);
      targetElement.removeEventListener('touchend', touchendHandler);
      targetElement.removeEventListener('contextmenu', contextHandler);
      targetElement.style.userSelect = beforeTargetStyle;
    };

    targetElement.addEventListener('touchend', touchendHandler);
    targetElement.removeEventListener('contextmenu', contextHandler);
    targetElement.style.userSelect = 'none';
```
- **ロングタップではない**と判断する為の `touchend`トリガーにハンドラを定義しています
- `beforeTargetStyle` と `contextmenu` は、ロングタップ時にコンテキストメニューが出てきてしまうのを抑制する為に定義しています


# 4. あとがき
という事で、思い込みで安請け合いをしたら苦労した話…ではなく、ロングタップを判断するユーティリティ作った話でした。

そろそろ、標準でロングタップトリガーを準備してくれてもいいと思うわけですが、テキスト選択等のコンテキストメニューとぶつかりやすそうで二の足を踏んでいるのでしょうか。

良い子の皆様におかれましては、安請け合い等をして泥沼にハマらない事を祈っております。